From 6c2786a83edd8395b3630c5ad2529a0c8d8bbf6a Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:19:50 -0800 Subject: [PATCH] refactor(api-markdown-documenter): Allow deeper customization of output folder structure (#23366) Previously, users could control certain aspects of the output documentation suite's file-system hierarchy via the `documentBoundaries` and `hierarchyBoundaries` properties of the transformation configuration. One particular limitation of this setup was that items yielding folder-wise hierarchy (`hierarchyBoundaries`) could never place their own document _inside_ of their own hierarchy. This naturally lent itself to a pattern where output would commonly be formatted as: ``` - foo.md - foo - bar.md - baz.md ``` This pattern works fine for many site generation systems - a link to `/foo` will end up pointing `foo.md` and a link to `/foo/bar` will end up pointing to `foo/bar.md`. But some systems (e.g. `Docusaurus`) don't handle this well, and instead prefer setups like the following: ``` - foo - index.md - bar.md - baz.md ``` With the previous configuration options, this pattern was not possible, but now is. Additionally, this pattern is _more_ commonly accepted, so lack of support for this was a real detriment. Such patterns can now be produced via the consolidated `hierarchy` property, while still allowing full file-naming flexibility. ### Notes for reviewers I would recommend starting with `Hierarchy.ts` - it contains the new configuration options related to hierarchy. The rest of the PR is predominantly respecting that new configuration setup. To keep things relatively simple, system defaults and test configurations have been intentionally made to preserve existing system default behaviors. As a result, you'll notice that none of the end-to-end tests have updated collateral. However, the intention _is_ to update default behaviors, and to add more end-to-end test configurations. For now, I have left a handful of TODOs in configuration defaults and test configurations - I will address those in a follow-up PR. --- tools/api-markdown-documenter/CHANGELOG.md | 121 +++++ .../api-markdown-documenter.alpha.api.md | 87 +++- .../api-markdown-documenter.beta.api.md | 87 +++- .../api-markdown-documenter.public.api.md | 87 +++- .../src/ApiItemUtilitiesModule.ts | 2 +- .../src/LoggingConfiguration.ts | 2 +- .../ApiItemTransformUtilities.ts | 397 +++++++---------- .../api-item-transforms/TransformApiItem.ts | 4 +- .../api-item-transforms/TransformApiModel.ts | 8 +- .../src/api-item-transforms/Utilities.ts | 6 +- .../configuration/Configuration.ts | 3 +- .../configuration/DocumentationSuite.ts | 236 +++------- .../configuration/Hierarchy.ts | 419 ++++++++++++++++++ .../configuration/index.ts | 31 +- .../CreateSectionForApiItem.ts | 2 +- .../TransformApiClass.ts | 2 +- .../TransformApiInterface.ts | 2 +- .../TransformApiModuleLike.ts | 2 +- .../api-item-transforms/helpers/Helpers.ts | 51 ++- .../src/api-item-transforms/index.ts | 16 +- tools/api-markdown-documenter/src/index.ts | 28 +- .../src/test/EndToEndTestUtilities.ts | 183 ++++++++ .../src/test/EndToEndTests.ts | 226 ---------- .../src/test/HtmlEndToEnd.test.ts | 193 ++++---- .../src/test/MarkdownEndToEnd.test.ts | 190 ++++---- .../test/TransformApiModelEndToEnd.test.ts | 79 ++++ .../src/utilities/TypeUtilities.ts | 29 ++ .../src/utilities/index.ts | 6 +- 28 files changed, 1528 insertions(+), 971 deletions(-) create mode 100644 tools/api-markdown-documenter/src/api-item-transforms/configuration/Hierarchy.ts create mode 100644 tools/api-markdown-documenter/src/test/EndToEndTestUtilities.ts delete mode 100644 tools/api-markdown-documenter/src/test/EndToEndTests.ts create mode 100644 tools/api-markdown-documenter/src/test/TransformApiModelEndToEnd.test.ts create mode 100644 tools/api-markdown-documenter/src/utilities/TypeUtilities.ts diff --git a/tools/api-markdown-documenter/CHANGELOG.md b/tools/api-markdown-documenter/CHANGELOG.md index e894a70422c9..16d28fcd8662 100644 --- a/tools/api-markdown-documenter/CHANGELOG.md +++ b/tools/api-markdown-documenter/CHANGELOG.md @@ -51,6 +51,127 @@ await MarkdownRenderer.renderApiModel({ }); ``` +#### Update pattern for controlling file-wise hierarchy + +Previously, users could control certain aspects of the output documentation suite's file-system hierarchy via the `documentBoundaries` and `hierarchyBoundaries` properties of the transformation configuration. +One particular limitation of this setup was that items yielding folder-wise hierarchy (`hierarchyBoundaries`) could never place their own document _inside_ of their own hierarchy. +This naturally lent itself to a pattern where output would commonly be formatted as: + +``` +- foo.md +- foo + - bar.md + - baz.md +``` + +This pattern works fine for many site generation systems - a link to `/foo` will end up pointing `foo.md` and a link to `/foo/bar` will end up pointing to `foo/bar.md`. +But some systems (e.g. `Docusaurus`) don't handle this well, and instead prefer setups like the following: + +``` +- foo + - index.md + - bar.md + - baz.md +``` + +With the previous configuration options, this pattern was not possible, but now is. +Additionally, this pattern is _more_ commonly accepted, so lack of support for this was a real detriment. + +Such patterns can now be produced via the consolidated `hierarchy` property, while still allowing full file-naming flexibility. + +##### Related changes + +For consistency / discoverability, the `DocumentationSuiteConfiguration.getFileNameForItem` property has also been moved under the new `hierarchy` property (`HierarchyConfiguration`) and renamed to `getDocumentName`. + +Additionally, where previously that property controlled both the document _and_ folder naming corresponding to a given API item, folder naming can now be controlled independently via the `getFolderName` property. + +##### Example migration + +Consider the following configuration: + +```typescript +const config = { + ... + documentBoundaries: [ + ApiItemKind.Class, + ApiItemKind.Interface, + ApiItemKind.Namespace, + ], + hierarchyBoundaries: [ + ApiItemKind.Namespace, + ] + ... +} +``` + +With this configuration, `Class`, `Interface`, and `Namespace` API items would yield their own documents (rather than being rendered to a parent item's document), and `Namespace` items would additionally generate folder hierarchy (child items rendered to their own documents would be placed under a sub-directory). + +Output for this case might look something like the following: + +``` +- package.md +- class.md +- interface.md +- namespace.md +- namespace + - namespace-member-a.md + - namespace-member-b.md +``` + +This same behavior can now be configured via the following: + +```typescript +const config = { + ... + hierarchy: { + [ApiItemKind.Class]: HierarchyKind.Document, + [ApiItemKind.Interface]: HierarchyKind.Document, + [ApiItemKind.Namespace]: { + kind: HierarchyKind.Folder, + documentPlacement: FolderDocumentPlacement.Outside, + }, + } + ... +} +``` + +Further, if you would prefer to place the resulting `Namespace` documents _under_ their resulting folder, you could use a configuration like the following: + +```typescript +const config = { + ... + hierarchy: { + [ApiItemKind.Class]: HierarchyKind.Document, + [ApiItemKind.Interface]: HierarchyKind.Document, + [ApiItemKind.Namespace]: { + kind: HierarchyKind.Folder, + documentPlacement: FolderDocumentPlacement.Inside, // <= + }, + getDocumentName: (apiItem) => { + switch(apiItem.kind) { + case ApiItemKind.Namespace: + return "index"; + default: + ... + } + } + } + ... +} +``` + +Output for this updated case might look something like the following: + +``` +- package.md +- class.md +- interface.md +- namespace + - index.md + - namespace-member-a.md + - namespace-member-b.md +``` + #### Type-renames - `ApiItemTransformationOptions` -> `ApiItemTransformations` diff --git a/tools/api-markdown-documenter/api-report/api-markdown-documenter.alpha.api.md b/tools/api-markdown-documenter/api-report/api-markdown-documenter.alpha.api.md index db1edbf11376..980908b72d9e 100644 --- a/tools/api-markdown-documenter/api-report/api-markdown-documenter.alpha.api.md +++ b/tools/api-markdown-documenter/api-report/api-markdown-documenter.alpha.api.md @@ -61,7 +61,7 @@ export interface ApiItemTransformationConfigurationBase { } // @public -export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, Partial, LoggingConfiguration { +export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, DocumentationSuiteOptions, LoggingConfiguration { readonly defaultSectionLayout?: (apiItem: ApiItem, childSections: SectionNode[] | undefined, config: ApiItemTransformationConfiguration) => SectionNode[]; readonly transformations?: Partial; } @@ -106,7 +106,7 @@ export interface ApiItemTransformations { declare namespace ApiItemUtilities { export { - doesItemRequireOwnDocument, + createQualifiedDocumentNameForApiItem, filterItems, getHeadingForApiItem, getLinkForApiItem, @@ -186,6 +186,9 @@ function createExamplesSection(apiItem: ApiItem, config: ApiItemTransformationCo // @public function createParametersSection(apiFunctionLike: ApiFunctionLike, config: ApiItemTransformationConfiguration): SectionNode | undefined; +// @public +function createQualifiedDocumentNameForApiItem(apiItem: ApiItem, hierarchyConfig: HierarchyConfiguration): string; + // @public function createRemarksSection(apiItem: ApiItem, config: ApiItemTransformationConfiguration): SectionNode | undefined; @@ -211,17 +214,22 @@ function createTypeParametersSection(typeParameters: readonly TypeParameter[], c export const defaultConsoleLogger: Logger; // @public -export namespace DefaultDocumentationSuiteOptions { - const defaultDocumentBoundaries: ApiMemberKind[]; - const defaultHierarchyBoundaries: ApiMemberKind[]; +export namespace DefaultDocumentationSuiteConfiguration { export function defaultGetAlertsForItem(apiItem: ApiItem): string[]; - export function defaultGetFileNameForItem(apiItem: ApiItem): string; export function defaultGetHeadingTextForItem(apiItem: ApiItem): string; export function defaultGetLinkTextForItem(apiItem: ApiItem): string; export function defaultGetUriBaseOverrideForItem(): string | undefined; export function defaultSkipPackage(): boolean; } +// @public @sealed +export type DocumentationHierarchyConfiguration = SectionHierarchyConfiguration | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + +// @public @sealed +export interface DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind; +} + // @public export interface DocumentationLiteralNode extends Literal, DocumentationNode { readonly isLiteral: true; @@ -306,13 +314,11 @@ export abstract class DocumentationParentNodeBase string[]; - readonly getFileNameForItem: (apiItem: ApiItem) => string; readonly getHeadingTextForItem: (apiItem: ApiItem) => string; readonly getLinkTextForItem: (apiItem: ApiItem) => string; readonly getUriBaseOverrideForItem: (apiItem: ApiItem) => string | undefined; - readonly hierarchyBoundaries: HierarchyBoundaries; + readonly hierarchy: HierarchyConfiguration; readonly includeBreadcrumb: boolean; readonly includeTopLevelDocumentHeading: boolean; readonly minimumReleaseLevel: Exclude; @@ -320,7 +326,14 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export interface DocumentHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Document; +} // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +372,6 @@ export namespace DocumentWriter { export function create(): DocumentWriter; } -// @public -function doesItemRequireOwnDocument(apiItem: ApiItem, documentBoundaries: DocumentBoundaries): boolean; - // @public export class FencedCodeBlockNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], language?: string); @@ -380,6 +390,18 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export interface FolderHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly documentPlacement: FolderDocumentPlacement; + readonly kind: HierarchyKind.Folder; +} + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +471,39 @@ export class HeadingNode extends DocumentationParentNodeBase]: DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]: DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]: DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]: DocumentHierarchyConfiguration; + readonly getDocumentName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; + +// @public +export enum HierarchyKind { + Document = "Document", + Folder = "Folder", + Section = "Section" +} + +// @public +export type HierarchyOptions = { + /** + * Hierarchy configuration for the API item kind. + */ + readonly [Kind in Exclude]?: HierarchyKind | DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]?: HierarchyKind.Document | HierarchyKind.Folder | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly getDocumentName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; // @public export class HorizontalRuleNode implements MultiLineDocumentationNode { @@ -699,6 +753,11 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export interface SectionHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Section; +} + // @public export class SectionNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], heading?: HeadingNode); diff --git a/tools/api-markdown-documenter/api-report/api-markdown-documenter.beta.api.md b/tools/api-markdown-documenter/api-report/api-markdown-documenter.beta.api.md index 01064319fa02..5ca0b2cca945 100644 --- a/tools/api-markdown-documenter/api-report/api-markdown-documenter.beta.api.md +++ b/tools/api-markdown-documenter/api-report/api-markdown-documenter.beta.api.md @@ -61,7 +61,7 @@ export interface ApiItemTransformationConfigurationBase { } // @public -export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, Partial, LoggingConfiguration { +export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, DocumentationSuiteOptions, LoggingConfiguration { readonly defaultSectionLayout?: (apiItem: ApiItem, childSections: SectionNode[] | undefined, config: ApiItemTransformationConfiguration) => SectionNode[]; readonly transformations?: Partial; } @@ -106,7 +106,7 @@ export interface ApiItemTransformations { declare namespace ApiItemUtilities { export { - doesItemRequireOwnDocument, + createQualifiedDocumentNameForApiItem, filterItems, getHeadingForApiItem, getLinkForApiItem, @@ -186,6 +186,9 @@ function createExamplesSection(apiItem: ApiItem, config: ApiItemTransformationCo // @public function createParametersSection(apiFunctionLike: ApiFunctionLike, config: ApiItemTransformationConfiguration): SectionNode | undefined; +// @public +function createQualifiedDocumentNameForApiItem(apiItem: ApiItem, hierarchyConfig: HierarchyConfiguration): string; + // @public function createRemarksSection(apiItem: ApiItem, config: ApiItemTransformationConfiguration): SectionNode | undefined; @@ -211,17 +214,22 @@ function createTypeParametersSection(typeParameters: readonly TypeParameter[], c export const defaultConsoleLogger: Logger; // @public -export namespace DefaultDocumentationSuiteOptions { - const defaultDocumentBoundaries: ApiMemberKind[]; - const defaultHierarchyBoundaries: ApiMemberKind[]; +export namespace DefaultDocumentationSuiteConfiguration { export function defaultGetAlertsForItem(apiItem: ApiItem): string[]; - export function defaultGetFileNameForItem(apiItem: ApiItem): string; export function defaultGetHeadingTextForItem(apiItem: ApiItem): string; export function defaultGetLinkTextForItem(apiItem: ApiItem): string; export function defaultGetUriBaseOverrideForItem(): string | undefined; export function defaultSkipPackage(): boolean; } +// @public @sealed +export type DocumentationHierarchyConfiguration = SectionHierarchyConfiguration | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + +// @public @sealed +export interface DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind; +} + // @public export interface DocumentationLiteralNode extends Literal, DocumentationNode { readonly isLiteral: true; @@ -306,13 +314,11 @@ export abstract class DocumentationParentNodeBase string[]; - readonly getFileNameForItem: (apiItem: ApiItem) => string; readonly getHeadingTextForItem: (apiItem: ApiItem) => string; readonly getLinkTextForItem: (apiItem: ApiItem) => string; readonly getUriBaseOverrideForItem: (apiItem: ApiItem) => string | undefined; - readonly hierarchyBoundaries: HierarchyBoundaries; + readonly hierarchy: HierarchyConfiguration; readonly includeBreadcrumb: boolean; readonly includeTopLevelDocumentHeading: boolean; readonly minimumReleaseLevel: Exclude; @@ -320,7 +326,14 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export interface DocumentHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Document; +} // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +372,6 @@ export namespace DocumentWriter { export function create(): DocumentWriter; } -// @public -function doesItemRequireOwnDocument(apiItem: ApiItem, documentBoundaries: DocumentBoundaries): boolean; - // @public export class FencedCodeBlockNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], language?: string); @@ -380,6 +390,18 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export interface FolderHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly documentPlacement: FolderDocumentPlacement; + readonly kind: HierarchyKind.Folder; +} + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +471,39 @@ export class HeadingNode extends DocumentationParentNodeBase]: DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]: DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]: DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]: DocumentHierarchyConfiguration; + readonly getDocumentName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; + +// @public +export enum HierarchyKind { + Document = "Document", + Folder = "Folder", + Section = "Section" +} + +// @public +export type HierarchyOptions = { + /** + * Hierarchy configuration for the API item kind. + */ + readonly [Kind in Exclude]?: HierarchyKind | DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]?: HierarchyKind.Document | HierarchyKind.Folder | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly getDocumentName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; // @public export class HorizontalRuleNode implements MultiLineDocumentationNode { @@ -685,6 +739,11 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export interface SectionHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Section; +} + // @public export class SectionNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], heading?: HeadingNode); diff --git a/tools/api-markdown-documenter/api-report/api-markdown-documenter.public.api.md b/tools/api-markdown-documenter/api-report/api-markdown-documenter.public.api.md index 5535ba42b2cb..539731dfc08a 100644 --- a/tools/api-markdown-documenter/api-report/api-markdown-documenter.public.api.md +++ b/tools/api-markdown-documenter/api-report/api-markdown-documenter.public.api.md @@ -61,7 +61,7 @@ export interface ApiItemTransformationConfigurationBase { } // @public -export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, Partial, LoggingConfiguration { +export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, DocumentationSuiteOptions, LoggingConfiguration { readonly defaultSectionLayout?: (apiItem: ApiItem, childSections: SectionNode[] | undefined, config: ApiItemTransformationConfiguration) => SectionNode[]; readonly transformations?: Partial; } @@ -106,7 +106,7 @@ export interface ApiItemTransformations { declare namespace ApiItemUtilities { export { - doesItemRequireOwnDocument, + createQualifiedDocumentNameForApiItem, filterItems, getHeadingForApiItem, getLinkForApiItem, @@ -186,6 +186,9 @@ function createExamplesSection(apiItem: ApiItem, config: ApiItemTransformationCo // @public function createParametersSection(apiFunctionLike: ApiFunctionLike, config: ApiItemTransformationConfiguration): SectionNode | undefined; +// @public +function createQualifiedDocumentNameForApiItem(apiItem: ApiItem, hierarchyConfig: HierarchyConfiguration): string; + // @public function createRemarksSection(apiItem: ApiItem, config: ApiItemTransformationConfiguration): SectionNode | undefined; @@ -211,17 +214,22 @@ function createTypeParametersSection(typeParameters: readonly TypeParameter[], c export const defaultConsoleLogger: Logger; // @public -export namespace DefaultDocumentationSuiteOptions { - const defaultDocumentBoundaries: ApiMemberKind[]; - const defaultHierarchyBoundaries: ApiMemberKind[]; +export namespace DefaultDocumentationSuiteConfiguration { export function defaultGetAlertsForItem(apiItem: ApiItem): string[]; - export function defaultGetFileNameForItem(apiItem: ApiItem): string; export function defaultGetHeadingTextForItem(apiItem: ApiItem): string; export function defaultGetLinkTextForItem(apiItem: ApiItem): string; export function defaultGetUriBaseOverrideForItem(): string | undefined; export function defaultSkipPackage(): boolean; } +// @public @sealed +export type DocumentationHierarchyConfiguration = SectionHierarchyConfiguration | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + +// @public @sealed +export interface DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind; +} + // @public export interface DocumentationLiteralNode extends Literal, DocumentationNode { readonly isLiteral: true; @@ -306,13 +314,11 @@ export abstract class DocumentationParentNodeBase string[]; - readonly getFileNameForItem: (apiItem: ApiItem) => string; readonly getHeadingTextForItem: (apiItem: ApiItem) => string; readonly getLinkTextForItem: (apiItem: ApiItem) => string; readonly getUriBaseOverrideForItem: (apiItem: ApiItem) => string | undefined; - readonly hierarchyBoundaries: HierarchyBoundaries; + readonly hierarchy: HierarchyConfiguration; readonly includeBreadcrumb: boolean; readonly includeTopLevelDocumentHeading: boolean; readonly minimumReleaseLevel: Exclude; @@ -320,7 +326,14 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export interface DocumentHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Document; +} // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +372,6 @@ export namespace DocumentWriter { export function create(): DocumentWriter; } -// @public -function doesItemRequireOwnDocument(apiItem: ApiItem, documentBoundaries: DocumentBoundaries): boolean; - // @public export class FencedCodeBlockNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], language?: string); @@ -380,6 +390,18 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export interface FolderHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly documentPlacement: FolderDocumentPlacement; + readonly kind: HierarchyKind.Folder; +} + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +471,39 @@ export class HeadingNode extends DocumentationParentNodeBase]: DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]: DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]: DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]: DocumentHierarchyConfiguration; + readonly getDocumentName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; + +// @public +export enum HierarchyKind { + Document = "Document", + Folder = "Folder", + Section = "Section" +} + +// @public +export type HierarchyOptions = { + /** + * Hierarchy configuration for the API item kind. + */ + readonly [Kind in Exclude]?: HierarchyKind | DocumentationHierarchyConfiguration; +} & { + readonly [ApiItemKind.Model]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly [ApiItemKind.Package]?: HierarchyKind.Document | HierarchyKind.Folder | DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + readonly [ApiItemKind.EntryPoint]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + readonly getDocumentName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + readonly getFolderName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; // @public export class HorizontalRuleNode implements MultiLineDocumentationNode { @@ -663,6 +717,11 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export interface SectionHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + readonly kind: HierarchyKind.Section; +} + // @public export class SectionNode extends DocumentationParentNodeBase implements MultiLineDocumentationNode { constructor(children: DocumentationNode[], heading?: HeadingNode); diff --git a/tools/api-markdown-documenter/src/ApiItemUtilitiesModule.ts b/tools/api-markdown-documenter/src/ApiItemUtilitiesModule.ts index 49882c369c86..354f617cb956 100644 --- a/tools/api-markdown-documenter/src/ApiItemUtilitiesModule.ts +++ b/tools/api-markdown-documenter/src/ApiItemUtilitiesModule.ts @@ -8,7 +8,7 @@ */ export { - doesItemRequireOwnDocument, + createQualifiedDocumentNameForApiItem, filterItems, getHeadingForApiItem, getLinkForApiItem, diff --git a/tools/api-markdown-documenter/src/LoggingConfiguration.ts b/tools/api-markdown-documenter/src/LoggingConfiguration.ts index 0da26d92f2d0..fafa881344e4 100644 --- a/tools/api-markdown-documenter/src/LoggingConfiguration.ts +++ b/tools/api-markdown-documenter/src/LoggingConfiguration.ts @@ -6,7 +6,7 @@ import type { Logger } from "./Logging.js"; /** - * Common base interface for configuration interfaces. + * Common base interface for configurations that take a logger. * * @public */ diff --git a/tools/api-markdown-documenter/src/api-item-transforms/ApiItemTransformUtilities.ts b/tools/api-markdown-documenter/src/api-item-transforms/ApiItemTransformUtilities.ts index 97ddf511fe70..d78ec257c746 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/ApiItemTransformUtilities.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/ApiItemTransformUtilities.ts @@ -3,24 +3,28 @@ * Licensed under the MIT License. */ -import * as Path from "node:path"; +import { strict as assert } from "node:assert"; import { type ApiItem, ApiItemKind, ReleaseTag } from "@microsoft/api-extractor-model"; import type { Heading } from "../Heading.js"; import type { Link } from "../Link.js"; import { + getApiItemKind, + getFilteredParent, getFileSafeNameForApiItem, getReleaseTag, - getApiItemKind, type ValidApiItemKind, - getFilteredParent, } from "../utilities/index.js"; -import type { - ApiItemTransformationConfiguration, - DocumentBoundaries, - HierarchyBoundaries, +import { + FolderDocumentPlacement, + HierarchyKind, + type ApiItemTransformationConfiguration, + type DocumentHierarchyConfiguration, + type FolderHierarchyConfiguration, + type DocumentationHierarchyConfiguration, + type HierarchyConfiguration, } from "./configuration/index.js"; /** @@ -28,33 +32,13 @@ import type { */ /** - * Gets the nearest ancestor of the provided item that will have its own rendered document. - * - * @remarks - * This can be useful for determining the file path the item will ultimately be rendered under, - * as well as for generating links. - * - * @param apiItem - The API item for which we are generating a file path. - * @param documentBoundaries - See {@link DocumentBoundaries} + * API item paired with its hierarchy config. */ -function getFirstAncestorWithOwnDocument( - apiItem: ApiItem, - documentBoundaries: DocumentBoundaries, -): ApiItem { - // Walk parentage until we reach an item kind that gets rendered to its own document. - // That is the document we will target with the generated link. - let hierarchyItem: ApiItem = apiItem; - while (!doesItemRequireOwnDocument(hierarchyItem, documentBoundaries)) { - const parent = getFilteredParent(hierarchyItem); - if (parent === undefined) { - throw new Error( - `Walking hierarchy from "${apiItem.displayName}" does not converge on an item that is rendered ` + - `to its own document.`, - ); - } - hierarchyItem = parent; - } - return hierarchyItem; +export interface ApiItemWithHierarchy< + THierarchy extends DocumentationHierarchyConfiguration = DocumentationHierarchyConfiguration, +> { + readonly apiItem: ApiItem; + readonly hierarchy: THierarchy; } /** @@ -98,7 +82,7 @@ function getLinkUrlForApiItem( config: ApiItemTransformationConfiguration, ): string { const uriBase = config.getUriBaseOverrideForItem(apiItem) ?? config.uriRoot; - let documentPath = getApiItemPath(apiItem, config).join("/"); + let documentPath = getDocumentPathForApiItem(apiItem, config.hierarchy); // Omit "index" file name from path generated in links. // This can be considered an optimization in most cases, but some documentation systems also special-case @@ -109,7 +93,7 @@ function getLinkUrlForApiItem( // Don't bother with heading ID if we are linking to the root item of a document let headingPostfix = ""; - if (!doesItemRequireOwnDocument(apiItem, config.documentBoundaries)) { + if (!doesItemRequireOwnDocument(apiItem, config.hierarchy)) { const headingId = getHeadingIdForApiItem(apiItem, config); headingPostfix = `#${headingId}`; } @@ -118,110 +102,156 @@ function getLinkUrlForApiItem( } /** - * Gets the path to the document for the specified API item. - * - * @remarks - * - * In the case of an item that does not get rendered to its own document, this will point to the document - * of the ancestor item under which the provided item will be rendered. + * Walks up the provided API item's hierarchy until and API item is found that matches the provided predicate. * - * The generated path is relative to {@link ApiItemTransformationConfiguration.uriRoot}. + * @returns The matching item, if one was found. Otherwise, `undefined`. * * @param apiItem - The API item for which we are generating a file path. - * @param config - See {@link ApiItemTransformationConfiguration}. + * @param predicate - A function that returns `true` when the desired item is found. */ -export function getDocumentPathForApiItem( +function findInHierarchy( apiItem: ApiItem, - config: ApiItemTransformationConfiguration, -): string { - const pathSegments = getApiItemPath(apiItem, config); - return Path.join(...pathSegments); + predicate: (item: ApiItem) => boolean, +): ApiItem | undefined { + let current: ApiItem | undefined = apiItem; + do { + if (predicate(current)) { + return current; + } + current = getFilteredParent(current); + } while (current !== undefined); + + return undefined; } /** - * Gets the path to the specified API item, represented as an ordered list of path segments. + * Gets the nearest ancestor of the provided item that will have its own rendered document. + * + * @remarks + * This can be useful for determining the file path the item will ultimately be rendered under, + * as well as for generating links. * * @param apiItem - The API item for which we are generating a file path. - * @param config - See {@link ApiItemTransformationConfiguration}. + * @param hierarchyConfig - See {@link HierarchyConfiguration} */ -function getApiItemPath(apiItem: ApiItem, config: ApiItemTransformationConfiguration): string[] { - const targetDocumentItem = getFirstAncestorWithOwnDocument(apiItem, config.documentBoundaries); +function getFirstAncestorWithOwnDocument( + apiItem: ApiItem, + hierarchyConfig: HierarchyConfiguration, +): ApiItemWithHierarchy { + // Walk parentage until we reach an item kind that gets rendered to its own document. + // That is the document we will target with the generated link. + const documentItem = findInHierarchy(apiItem, (item) => + doesItemRequireOwnDocument(item, hierarchyConfig), + ); - const fileName = getDocumentNameForApiItem(apiItem, config); + if (documentItem === undefined) { + throw new Error( + `No ancestor of API item "${apiItem.displayName}" found that requires its own document.`, + ); + } - // Filtered ancestry in ascending order - const documentAncestry = getAncestralHierarchy(targetDocumentItem, (hierarchyItem) => - doesItemGenerateHierarchy(hierarchyItem, config.hierarchyBoundaries), - ); + const documentItemKind = getApiItemKind(documentItem); + const documentHierarchyConfig = hierarchyConfig[documentItemKind]; + assert(documentHierarchyConfig.kind !== HierarchyKind.Section); - return [ - fileName, - ...documentAncestry.map((hierarchyItem) => - getDocumentNameForApiItem(hierarchyItem, config), - ), - ].reverse(); + return { + apiItem: documentItem, + hierarchy: documentHierarchyConfig, + }; } /** - * Gets the document name for the specified API item. + * Gets the path to the document for the specified API item. * * @remarks * - * In the case of an item that does not get rendered to its own document, this will be the file name for the document + * In the case of an item that does not get rendered to its own document, this will point to the document * of the ancestor item under which the provided item will be rendered. * - * Note: This is strictly the name of the file, not a path to that file. - * To get the path, use {@link getDocumentPathForApiItem}. + * The generated path is relative to {@link ApiItemTransformationConfiguration.uriRoot}. * * @param apiItem - The API item for which we are generating a file path. - * @param config - See {@link ApiItemTransformationConfiguration}. + * @param hierarchyConfig - See {@link HierarchyConfiguration} */ -function getDocumentNameForApiItem( +export function getDocumentPathForApiItem( apiItem: ApiItem, - config: ApiItemTransformationConfiguration, + hierarchyConfig: HierarchyConfiguration, ): string { - const targetDocumentItem = getFirstAncestorWithOwnDocument(apiItem, config.documentBoundaries); + const targetDocument = getFirstAncestorWithOwnDocument(apiItem, hierarchyConfig); + const targetDocumentName = hierarchyConfig.getDocumentName( + targetDocument.apiItem, + hierarchyConfig, + ); - let unscopedFileName = config.getFileNameForItem(targetDocumentItem); + const pathSegments: string[] = []; - // For items of kinds other than `Model` or `Package` (which are handled specially file-system-wise), - // append the item kind to disambiguate file names resulting from members whose names may conflict in a - // casing-agnostic context (e.g. type "Foo" and function "foo"). + // For the document itself, if its item creates folder-wise hierarchy, we need to refer to the hierarchy config + // to determine whether or not it should be placed inside or outside that folder. if ( - targetDocumentItem.kind !== ApiItemKind.Model && - targetDocumentItem.kind !== ApiItemKind.Package + targetDocument.hierarchy.kind === HierarchyKind.Folder && + targetDocument.hierarchy.documentPlacement === FolderDocumentPlacement.Inside ) { - unscopedFileName = `${unscopedFileName}-${targetDocumentItem.kind.toLocaleLowerCase()}`; + const folderName = hierarchyConfig.getFolderName(targetDocument.apiItem, hierarchyConfig); + pathSegments.push(`${folderName}/${targetDocumentName}`); + } else { + pathSegments.push(targetDocumentName); } - // Walk parentage up until we reach the first ancestor which injects directory hierarchy. - // Qualify generated file name to ensure no conflicts within that directory. - let hierarchyItem = getFilteredParent(targetDocumentItem); - if (hierarchyItem === undefined) { - // If there is no parent item, then we can just return the file name unmodified - return unscopedFileName; + let currentItem: ApiItem | undefined = getFilteredParent(targetDocument.apiItem); + while (currentItem !== undefined) { + const currentItemKind = getApiItemKind(currentItem); + const currentItemHierarchy = hierarchyConfig[currentItemKind]; + // Push path segments for all folders in the hierarchy + if (currentItemHierarchy.kind === HierarchyKind.Folder) { + const folderName = hierarchyConfig.getFolderName(currentItem, hierarchyConfig); + pathSegments.push(folderName); + } + currentItem = getFilteredParent(currentItem); } - let scopedFileName = unscopedFileName; - while ( - hierarchyItem.kind !== ApiItemKind.Model && - !doesItemGenerateHierarchy(hierarchyItem, config.hierarchyBoundaries) - ) { - const segmentName = config.getFileNameForItem(hierarchyItem); - if (segmentName.length === 0) { - throw new Error("Segment name must be non-empty."); - } + // Hierarchy is built from the root down, so reverse the segments to get the correct file path ordering + pathSegments.reverse(); - scopedFileName = `${segmentName}-${scopedFileName}`; + return pathSegments.join("/"); +} - const parent = getFilteredParent(hierarchyItem); - if (parent === undefined) { - break; - } - hierarchyItem = parent; +/** + * Generates a qualified document name for the specified API item aimed at preventing name collisions, accounting for folder hierarchy. + * + * @param apiItem - The API item for which we are generating a qualified name + * @param hierarchyConfig - See {@link HierarchyConfiguration} + * + * @public + */ +export function createQualifiedDocumentNameForApiItem( + apiItem: ApiItem, + hierarchyConfig: HierarchyConfiguration, +): string { + const apiItemKind = getApiItemKind(apiItem); + let documentName = getFileSafeNameForApiItem(apiItem); + if (apiItemKind !== ApiItemKind.Package) { + // If the item is not a package, append its "kind" to the document name to ensure uniqueness. + // Packages strictly live at the root of the document hierarchy (beneath the model), and only + // packages may appear there, so this information is redundant. + const postfix = apiItemKind.toLocaleLowerCase(); + documentName = `${documentName}-${postfix}`; + } + + // Walk up hierarchy until we find the nearest ancestor that yields folder hierarchy (or until we hit the model root). + // Qualify the document name with all ancestral items up to that point to ensure document name uniqueness. + + let currentItem: ApiItem | undefined = getFilteredParent(apiItem); + + while ( + currentItem !== undefined && + currentItem.kind !== "Model" && + hierarchyConfig[getApiItemKind(currentItem)].kind !== HierarchyKind.Folder + ) { + documentName = `${getFileSafeNameForApiItem(currentItem)}-${documentName}`; + currentItem = getFilteredParent(currentItem); } - return scopedFileName; + return documentName; } /** @@ -241,19 +271,22 @@ export function getHeadingForApiItem( headingLevel?: number, ): Heading { // Don't generate an ID for the root heading - const id = doesItemRequireOwnDocument(apiItem, config.documentBoundaries) + const id = doesItemRequireOwnDocument(apiItem, config.hierarchy) ? undefined : getHeadingIdForApiItem(apiItem, config); + const title = config.getHeadingTextForItem(apiItem); return { - title: config.getHeadingTextForItem(apiItem), + title, id, level: headingLevel, }; } +// TODO: this doesn't actually return `undefined` for own document. Verify and fix. /** - * Generates a unique heading ID for the provided API item. + * Generates a heading ID for the provided API item. + * Guaranteed to be unique within the document to which the API item is being rendered. * * @remarks * Notes: @@ -262,7 +295,7 @@ export function getHeadingForApiItem( * Any links pointing to this item may simply link to the document; no heading ID is needed. * * - The resulting ID is context-dependent. In order to guarantee uniqueness, it will need to express - * hierarchical information up to the ancester item whose document the specified item will ultimately be rendered to. + * hierarchical information up to the ancestor item whose document the specified item will ultimately be rendered to. * * @param apiItem - The API item for which the heading ID is being generated. * @param config - See {@link ApiItemTransformationConfiguration}. @@ -279,7 +312,7 @@ function getHeadingIdForApiItem( // Walk parentage up until we reach the ancestor into whose document we're being rendered. // Generate ID information for everything back to that point let hierarchyItem = apiItem; - while (!doesItemRequireOwnDocument(hierarchyItem, config.documentBoundaries)) { + while (!doesItemRequireOwnDocument(hierarchyItem, config.hierarchy)) { const qualifiedName = getFileSafeNameForApiItem(hierarchyItem); // Since we're walking up the tree, we'll build the string from the end for simplicity @@ -298,159 +331,35 @@ function getHeadingIdForApiItem( } /** - * Gets the ancestral hierarchy of the provided API item by walking up the parentage graph and emitting any items - * matching the `includePredecate` until it reaches an item that matches the `breakPredecate`. - * - * @remarks Notes: - * - * - This will not include the provided item itself, even if it matches the `includePredecate`. - * - * - This will not include the item matching the `breakPredecate`, even if they match the `includePredecate`. - * - * @param apiItem - The API item whose ancestral hierarchy is being queried. - * @param includePredecate - Predicate to determine which items in the hierarchy should be preserved in the - * returned list. The provided API item will not be included in the output, even if it would be included by this. - * @param breakPredicate - Predicate to determine when to break from the traversal and return. - * The item matching this predicate will not be included, even if it would be included by `includePredicate`. - * - * @returns The list of matching ancestor items, provided in *ascending* order. - */ -export function getAncestralHierarchy( - apiItem: ApiItem, - includePredecate: (apiItem: ApiItem) => boolean, - breakPredicate?: (apiItem: ApiItem) => boolean, -): ApiItem[] { - const matches: ApiItem[] = []; - - let hierarchyItem: ApiItem | undefined = getFilteredParent(apiItem); - while ( - hierarchyItem !== undefined && - (breakPredicate === undefined || !breakPredicate(hierarchyItem)) - ) { - if (includePredecate(hierarchyItem)) { - matches.push(hierarchyItem); - } - hierarchyItem = getFilteredParent(hierarchyItem); - } - return matches; -} - -/** - * Determines whether or not the specified API item kind is one that should be rendered to its own document. - * - * @remarks This is essentially a wrapper around {@link DocumentationSuiteConfiguration.documentBoundaries}, but also enforces - * system-wide invariants. + * Determines whether or not the specified API item is one that should be rendered to its own document + * (as opposed to being rendered to a section under some ancestor item's document). * - * Namely... - * - * - `Model` and `Package` items are *always* rendered to their own documents, regardless of the specified boundaries. - * - * - `EntryPoint` items are *never* rendered to their own documents (as they are completely ignored by this system), - * regardless of the specified boundaries. - * - * @param kind - The kind of API item. - * @param documentBoundaries - See {@link DocumentBoundaries} + * @param apiItem - The API being queried. + * @param config - See {@link ApiItemTransformationConfiguration}. * - * @returns `true` if the item should be rendered to its own document. `false` otherwise. + * @public */ export function doesItemKindRequireOwnDocument( - kind: ValidApiItemKind, - documentBoundaries: DocumentBoundaries, + apiItemKind: ValidApiItemKind, + hierarchyConfig: Required, ): boolean { - if ( - kind === ApiItemKind.EntryPoint || - kind === ApiItemKind.Model || - kind === ApiItemKind.Package - ) { - return true; - } - return documentBoundaries.includes(kind); + const hierarchy = hierarchyConfig[apiItemKind]; + return hierarchy.kind !== HierarchyKind.Section; } /** - * Determines whether or not the specified API item is one that should be rendered to its own document. - * - * @remarks - * - * This is essentially a wrapper around {@link DocumentationSuiteConfiguration.hierarchyBoundaries}, but also enforces - * system-wide invariants. - * - * Namely... - * - * - `Package` items are *always* rendered to their own documents, regardless of the specified boundaries. - * - * - `EntryPoint` items are *never* rendered to their own documents (as they are completely ignored by this system), - * regardless of the specified boundaries. + * Determines whether or not the specified API item is one that should be rendered to its own document + * (as opposed to being rendered to a section under some ancestor item's document). * * @param apiItem - The API being queried. - * @param documentBoundaries - See {@link DocumentBoundaries} - * - * @public + * @param config - See {@link ApiItemTransformationConfiguration}. */ export function doesItemRequireOwnDocument( apiItem: ApiItem, - documentBoundaries: DocumentBoundaries, -): boolean { - return doesItemKindRequireOwnDocument(getApiItemKind(apiItem), documentBoundaries); -} - -/** - * Determines whether or not the specified API item kind is one that should generate directory-wise hierarchy - * in the resulting documentation suite. - * I.e. whether or not child item documents should be generated under a sub-directory adjacent to the item in question. - * - * @remarks - * - * This is essentially a wrapper around {@link DocumentationSuiteConfiguration.hierarchyBoundaries}, but also enforces - * system-wide invariants. - * - * Namely... - * - * - `Package` items are *always* rendered to their own documents, regardless of the specified boundaries. - * - * - `EntryPoint` items are *never* rendered to their own documents (as they are completely ignored by this system), - * regardless of the specified boundaries. - * - * @param kind - The kind of API item. - * @param hierarchyBoundaries - See {@link HierarchyBoundaries} - * - * @returns `true` if the item should contribute to directory-wise hierarchy in the output. `false` otherwise. - */ -function doesItemKindGenerateHierarchy( - kind: ValidApiItemKind, - hierarchyBoundaries: HierarchyBoundaries, -): boolean { - if (kind === ApiItemKind.Model) { - // Model items always yield a document, and never introduce hierarchy - return false; - } - - if (kind === ApiItemKind.Package) { - return true; - } - if (kind === ApiItemKind.EntryPoint) { - // The same API item within a package can be included in multiple entry-points, so it doesn't make sense to - // include it in generated hierarchy. - return false; - } - return hierarchyBoundaries.includes(kind); -} - -/** - * Determines whether or not the specified API item is one that should generate directory-wise hierarchy - * in the resulting documentation suite. - * I.e. whether or not child item documents should be generated under a sub-directory adjacent to the item in question. - * - * @remarks This is based on the item's `kind`. See {@link doesItemKindGenerateHierarchy}. - * - * @param apiItem - The API item being queried. - * @param hierarchyBoundaries - See {@link HierarchyBoundaries} - */ -function doesItemGenerateHierarchy( - apiItem: ApiItem, - hierarchyBoundaries: HierarchyBoundaries, + hierarchyConfig: Required, ): boolean { - return doesItemKindGenerateHierarchy(getApiItemKind(apiItem), hierarchyBoundaries); + const itemKind = getApiItemKind(apiItem); + return doesItemKindRequireOwnDocument(itemKind, hierarchyConfig); } /** diff --git a/tools/api-markdown-documenter/src/api-item-transforms/TransformApiItem.ts b/tools/api-markdown-documenter/src/api-item-transforms/TransformApiItem.ts index f89c31efa94d..e93de7193b5d 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/TransformApiItem.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/TransformApiItem.ts @@ -39,7 +39,7 @@ import { createBreadcrumbParagraph, wrapInSection } from "./helpers/index.js"; * * This should only be called for API item kinds that are intended to be rendered to their own document * (as opposed to being rendered to the same document as their parent) per the provided `config` - * (see {@link DocumentationSuiteConfiguration.documentBoundaries}). + * (see {@link ApiItemTransformationConfiguration.hierarchy}). * * Also note that this should not be called for the following item kinds, which must be handled specially: * @@ -72,7 +72,7 @@ export function apiItemToDocument( ); } - if (!doesItemRequireOwnDocument(apiItem, config.documentBoundaries)) { + if (!doesItemRequireOwnDocument(apiItem, config.hierarchy)) { throw new Error( `"apiItemToDocument" called for an API item kind that is not intended to be rendered to its own document. Provided item kind: "${itemKind}".`, ); diff --git a/tools/api-markdown-documenter/src/api-item-transforms/TransformApiModel.ts b/tools/api-markdown-documenter/src/api-item-transforms/TransformApiModel.ts index d69a1453345a..419e51a364f1 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/TransformApiModel.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/TransformApiModel.ts @@ -127,13 +127,13 @@ export function transformApiModel(options: ApiItemTransformationOptions): Docume * @param config - See {@link ApiItemTransformationConfiguration} */ function getDocumentItems(apiItem: ApiItem, config: ApiItemTransformationConfiguration): ApiItem[] { - const { documentBoundaries } = config; + const { hierarchy } = config; const result: ApiItem[] = []; for (const childItem of apiItem.members) { if ( shouldItemBeIncluded(childItem, config) && - doesItemRequireOwnDocument(childItem, documentBoundaries) + doesItemRequireOwnDocument(childItem, hierarchy) ) { result.push(childItem); } @@ -158,7 +158,7 @@ function createDocumentForApiModel( logger.verbose(`Generating API Model document...`); - // Note: We don't render the breadcrumb for Model document, as it is always the root of the file hierarchical + // Note: We don't render the breadcrumb for Model document, as it is always the root of the file hierarchy. // Render body contents const sections = transformations[ApiItemKind.Model](apiModel, config); @@ -239,7 +239,7 @@ function createDocumentForMultiEntryPointPackage( sections.push(wrapInSection([createBreadcrumbParagraph(apiPackage, config)])); } - // Render list of entry-points + // Render list of links to entry-points, each of which will get its own document. const renderedEntryPointList = createEntryPointList(apiEntryPoints, config); if (renderedEntryPointList !== undefined) { sections.push( diff --git a/tools/api-markdown-documenter/src/api-item-transforms/Utilities.ts b/tools/api-markdown-documenter/src/api-item-transforms/Utilities.ts index edc906a01381..c455eecc7aae 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/Utilities.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/Utilities.ts @@ -33,15 +33,17 @@ export function createDocument( sections: SectionNode[], config: ApiItemTransformationConfiguration, ): DocumentNode { + const title = config.getHeadingTextForItem(documentItem); + // Wrap sections in a root section if top-level heading is requested. const contents = config.includeTopLevelDocumentHeading - ? [wrapInSection(sections, { title: config.getHeadingTextForItem(documentItem) })] + ? [wrapInSection(sections, { title })] : sections; return new DocumentNode({ apiItem: documentItem, children: contents, - documentPath: getDocumentPathForApiItem(documentItem, config), + documentPath: getDocumentPathForApiItem(documentItem, config.hierarchy), }); } diff --git a/tools/api-markdown-documenter/src/api-item-transforms/configuration/Configuration.ts b/tools/api-markdown-documenter/src/api-item-transforms/configuration/Configuration.ts index 4694bd1991b5..b5dfe46f0730 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/configuration/Configuration.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/configuration/Configuration.ts @@ -12,6 +12,7 @@ import { createSectionForApiItem } from "../default-implementations/index.js"; import { type DocumentationSuiteConfiguration, + type DocumentationSuiteOptions, getDocumentationSuiteConfigurationWithDefaults, } from "./DocumentationSuite.js"; import { @@ -89,7 +90,7 @@ export interface ApiItemTransformationConfiguration */ export interface ApiItemTransformationOptions extends ApiItemTransformationConfigurationBase, - Partial, + DocumentationSuiteOptions, LoggingConfiguration { /** * Optional overrides for the default transformations. diff --git a/tools/api-markdown-documenter/src/api-item-transforms/configuration/DocumentationSuite.ts b/tools/api-markdown-documenter/src/api-item-transforms/configuration/DocumentationSuite.ts index 2e2278518476..0246a6f6d5e9 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/configuration/DocumentationSuite.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/configuration/DocumentationSuite.ts @@ -12,82 +12,18 @@ import { } from "@microsoft/api-extractor-model"; import { - type ApiMemberKind, getApiItemKind, getConciseSignature, - getFileSafeNameForApiItem, - getFileSafeNameForApiItemName, getReleaseTag, getSingleLineExcerptText, - getUnscopedPackageName, isDeprecated, } from "../../utilities/index.js"; -/** - * List of item kinds for which separate documents should be generated. - * Items specified will be rendered to their own documents. - * Items not specified will be rendered into their parent's contents. - * - * @remarks Note that `Model` and `Package` items will *always* have separate documents generated for them, even if - * not specified. - * - * Also note that `EntryPoint` items will always be ignored by the system, even if specified here. - * - * @example - * - * A configuration like the following: - * - * ```typescript - * ... - * documentBoundaries: [ - * ApiItemKind.Namespace, - * ], - * ... - * ``` - * - * will result in separate documents being generated for `Namespace` items, but will not for other item kinds - * (`Classes`, `Interfaces`, etc.). - * - * @public - */ -export type DocumentBoundaries = ApiMemberKind[]; - -/** - * List of item kinds for which sub-directories will be generated, and under which child item documents will be created. - * If not specified for an item kind, any children of items of that kind will be generated adjacent to the parent. - * - * @remarks Note that `Package` items will *always* have separate documents generated for them, even if - * not specified. - * - * @example - * - * A configuration like the following: - * - * ```typescript - * ... - * hierarchyBoundaries: [ - * ApiItemKind.Namespace, - * ], - * ... - * ``` - * - * will result in documents rendered for children of the `Namespace` to be generated in a subdirectory named after - * the `Namespace` item. - * - * So for some namespace `Foo` with children `Bar` and `Baz` (assuming `Bar` and `Baz` are item kinds matching - * the configured {@link DocumentationSuiteConfiguration.documentBoundaries}), the resulting file structure would look like the - * following: - * - * ``` - * foo.md - * foo - * | bar.md - * | baz.md - * ``` - * - * @public - */ -export type HierarchyBoundaries = ApiMemberKind[]; +import { + getHierarchyConfigurationWithDefaults, + type HierarchyConfiguration, + type HierarchyOptions, +} from "./Hierarchy.js"; /** * Options for configuring the documentation suite generated by the API Item -\> Documentation Domain transformation. @@ -110,46 +46,14 @@ export interface DocumentationSuiteConfiguration { * * @defaultValue `true` * - * @remarks Note: `Model` items will never have a breadcrumb rendered, even if this is specfied. + * @remarks Note: `Model` items will never have a breadcrumb rendered, even if this is specified. */ readonly includeBreadcrumb: boolean; /** - * See {@link DocumentBoundaries}. - * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultDocumentBoundaries} + * {@link HierarchyConfiguration} to use for the provided API item. */ - readonly documentBoundaries: DocumentBoundaries; - - /** - * See {@link HierarchyBoundaries}. - * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultHierarchyBoundaries} - */ - readonly hierarchyBoundaries: HierarchyBoundaries; - - /** - * Generate a file name for the provided `ApiItem`. - * - * @remarks - * - * Note that this is not the complete file name, but the "leaf" component of the final file name. - * Additional prefixes and suffixes will be appended to ensure file name collisions do not occur. - * - * This also does not contain the file extension. - * - * @example - * - * We are given a class API item "Bar" in package "Foo", and this returns "foo". - * The final file name in this case might be something like "foo-bar-class". - * - * @param apiItem - The API item for which the pre-modification file name is being generated. - * - * @returns The pre-modification file name for the API item. - * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultGetFileNameForItem} - */ - readonly getFileNameForItem: (apiItem: ApiItem) => string; + readonly hierarchy: HierarchyConfiguration; /** * Optionally provide an override for the URI base used in links generated for the provided `ApiItem`. @@ -174,7 +78,7 @@ export interface DocumentationSuiteConfiguration { * * @returns The heading title for the API item. * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultGetHeadingTextForItem} + * @defaultValue {@link DefaultDocumentationSuiteConfiguration.defaultGetHeadingTextForItem} */ readonly getHeadingTextForItem: (apiItem: ApiItem) => string; @@ -185,7 +89,7 @@ export interface DocumentationSuiteConfiguration { * * @returns The text to use in the link to the API item. * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultGetLinkTextForItem} + * @defaultValue {@link DefaultDocumentationSuiteConfiguration.defaultGetLinkTextForItem} */ readonly getLinkTextForItem: (apiItem: ApiItem) => string; @@ -196,7 +100,7 @@ export interface DocumentationSuiteConfiguration { * * @returns The list of "alert" strings to display. * - * @defaultValue {@link DefaultDocumentationSuiteOptions.defaultGetAlertsForItem} + * @defaultValue {@link DefaultDocumentationSuiteConfiguration.defaultGetAlertsForItem} */ readonly getAlertsForItem: (apiItem: ApiItem) => string[]; @@ -238,62 +142,27 @@ export interface DocumentationSuiteConfiguration { } /** - * Contains a list of default documentation transformations, used by {@link DocumentationSuiteConfiguration}. + * Complete configuration documentation suite generation via the API Item -\> Documentation Domain transformation. * * @public */ -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace DefaultDocumentationSuiteOptions { - /** - * Default {@link DocumentationSuiteConfiguration.documentBoundaries}. - * - * Generates separate documents for the following API item kinds: - * - * - Class - * - * - Interface - * - * - Namespace - */ - export const defaultDocumentBoundaries: ApiMemberKind[] = [ - ApiItemKind.Class, - ApiItemKind.Interface, - ApiItemKind.Namespace, - ]; - +export type DocumentationSuiteOptions = Omit< + Partial, + "hierarchy" +> & { /** - * Default {@link DocumentationSuiteConfiguration.hierarchyBoundaries}. - * - * Creates sub-directories for the following API item kinds: - * - * - Namespace + * {@inheritDoc DocumentationSuiteConfiguration.hierarchy} */ - export const defaultHierarchyBoundaries: ApiMemberKind[] = [ApiItemKind.Namespace]; - - /** - * Default {@link DocumentationSuiteConfiguration.getFileNameForItem}. - * - * Uses the item's qualified API name, but is handled differently for the following items: - * - * - Model: Uses "index". - * - * - Package: Uses the unscoped package name. - */ - export function defaultGetFileNameForItem(apiItem: ApiItem): string { - const itemKind = getApiItemKind(apiItem); - switch (itemKind) { - case ApiItemKind.Model: { - return "index"; - } - case ApiItemKind.Package: { - return getFileSafeNameForApiItemName(getUnscopedPackageName(apiItem as ApiPackage)); - } - default: { - return getFileSafeNameForApiItem(apiItem); - } - } - } + readonly hierarchy?: HierarchyOptions; +}; +/** + * Contains a list of default {@link DocumentationSuiteConfiguration} functions. + * + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace DefaultDocumentationSuiteConfiguration { /** * Default {@link DocumentationSuiteConfiguration.getUriBaseOverrideForItem}. * @@ -306,11 +175,15 @@ export namespace DefaultDocumentationSuiteOptions { /** * Default {@link DocumentationSuiteConfiguration.getHeadingTextForItem}. * - * Uses the item's `displayName`, except for `Model` items, in which case the text "API Overview" is displayed. + * Uses the item's qualified API name, but is handled differently for the following items: + * + * - CallSignature, ConstructSignature, IndexSignature: Uses a cleaned up variation on the type signature. + * + * - Model: Uses "API Overview". */ export function defaultGetHeadingTextForItem(apiItem: ApiItem): string { - const itemKind = getApiItemKind(apiItem); - switch (itemKind) { + const kind = getApiItemKind(apiItem); + switch (kind) { case ApiItemKind.Model: { return "API Overview"; } @@ -395,33 +268,36 @@ export namespace DefaultDocumentationSuiteOptions { } } -/** - * Default {@link DocumentationSuiteConfiguration}. - */ -const defaultDocumentationSuiteConfiguration: DocumentationSuiteConfiguration = { - includeTopLevelDocumentHeading: true, - includeBreadcrumb: true, - documentBoundaries: DefaultDocumentationSuiteOptions.defaultDocumentBoundaries, - hierarchyBoundaries: DefaultDocumentationSuiteOptions.defaultHierarchyBoundaries, - getFileNameForItem: DefaultDocumentationSuiteOptions.defaultGetFileNameForItem, - getUriBaseOverrideForItem: DefaultDocumentationSuiteOptions.defaultGetUriBaseOverrideForItem, - getHeadingTextForItem: DefaultDocumentationSuiteOptions.defaultGetHeadingTextForItem, - getLinkTextForItem: DefaultDocumentationSuiteOptions.defaultGetLinkTextForItem, - getAlertsForItem: DefaultDocumentationSuiteOptions.defaultGetAlertsForItem, - skipPackage: DefaultDocumentationSuiteOptions.defaultSkipPackage, - minimumReleaseLevel: ReleaseTag.Internal, // Include everything in the input model -}; - /** * Gets a complete {@link DocumentationSuiteConfiguration} using the provided partial configuration, and filling * in the remainder with the documented defaults. */ export function getDocumentationSuiteConfigurationWithDefaults( - options?: Partial, + options?: DocumentationSuiteOptions, ): DocumentationSuiteConfiguration { + const hierarchy: HierarchyConfiguration = getHierarchyConfigurationWithDefaults( + options?.hierarchy, + ); + return { - ...defaultDocumentationSuiteConfiguration, - ...options, + hierarchy, + includeTopLevelDocumentHeading: options?.includeTopLevelDocumentHeading ?? true, + includeBreadcrumb: options?.includeBreadcrumb ?? true, + getUriBaseOverrideForItem: + options?.getUriBaseOverrideForItem ?? + DefaultDocumentationSuiteConfiguration.defaultGetUriBaseOverrideForItem, + getHeadingTextForItem: + options?.getHeadingTextForItem ?? + DefaultDocumentationSuiteConfiguration.defaultGetHeadingTextForItem, + getLinkTextForItem: + options?.getLinkTextForItem ?? + DefaultDocumentationSuiteConfiguration.defaultGetLinkTextForItem, + getAlertsForItem: + options?.getAlertsForItem ?? + DefaultDocumentationSuiteConfiguration.defaultGetAlertsForItem, + skipPackage: + options?.skipPackage ?? DefaultDocumentationSuiteConfiguration.defaultSkipPackage, + minimumReleaseLevel: options?.minimumReleaseLevel ?? ReleaseTag.Internal, }; } diff --git a/tools/api-markdown-documenter/src/api-item-transforms/configuration/Hierarchy.ts b/tools/api-markdown-documenter/src/api-item-transforms/configuration/Hierarchy.ts new file mode 100644 index 000000000000..1e201b9331fa --- /dev/null +++ b/tools/api-markdown-documenter/src/api-item-transforms/configuration/Hierarchy.ts @@ -0,0 +1,419 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { type ApiItem, ApiItemKind, type ApiPackage } from "@microsoft/api-extractor-model"; + +import { + getApiItemKind, + getUnscopedPackageName, + type ValidApiItemKind, +} from "../../utilities/index.js"; +import { createQualifiedDocumentNameForApiItem } from "../ApiItemTransformUtilities.js"; + +/** + * Kind of documentation suite hierarchy. + * + * @public + */ +export enum HierarchyKind { + /** + * The API item gets a section under the document representing an ancestor of the API item. + */ + Section = "Section", + + /** + * The API item gets its own document, in the folder for an ancestor of the API item. + */ + Document = "Document", + + /** + * The API item gets its own document, and generates folder hierarchy for all descendent API items. + */ + Folder = "Folder", +} + +/** + * {@link DocumentationHierarchyConfiguration} base interface. + * + * @remarks + * Not intended for external use. + * Only exists to share common properties between hierarchy configuration types. + * + * @sealed + * @public + */ +export interface DocumentationHierarchyConfigurationBase { + /** + * {@inheritDoc HierarchyKind} + */ + readonly kind: HierarchyKind; +} + +/** + * The corresponding API item will be placed in a section under the document representing an ancestor of the API item. + * + * @sealed + * @public + */ +export interface SectionHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + /** + * {@inheritDoc DocumentationHierarchyConfigurationBase.kind} + */ + readonly kind: HierarchyKind.Section; +} + +/** + * The corresponding API item will get its own document, in the folder for an ancestor of the API item. + * + * @sealed + * @public + */ +export interface DocumentHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + /** + * {@inheritDoc DocumentationHierarchyConfigurationBase.kind} + */ + readonly kind: HierarchyKind.Document; +} + +/** + * Placement of the API item's document relative to its generated folder. + * + * @public + */ +export enum FolderDocumentPlacement { + /** + * The document is placed inside its folder. + */ + Inside = "Inside", + + /** + * The document is placed outside (adjacent to) its folder. + */ + Outside = "Outside", +} + +/** + * The corresponding API item will get its own document, in the folder for an ancestor of the API item. + * + * @sealed + * @public + */ +export interface FolderHierarchyConfiguration extends DocumentationHierarchyConfigurationBase { + /** + * {@inheritDoc DocumentationHierarchyConfigurationBase.kind} + */ + readonly kind: HierarchyKind.Folder; + + /** + * Placement of the API item's document relative to its generated folder. + * + * @defaultValue {@link FolderDocumentPlacement.Outside} + * @privateRemarks TODO: change default to `inside` + */ + readonly documentPlacement: FolderDocumentPlacement; +} + +/** + * API item hierarchy configuration. + * + * @sealed + * @public + */ +export type DocumentationHierarchyConfiguration = + | SectionHierarchyConfiguration + | DocumentHierarchyConfiguration + | FolderHierarchyConfiguration; + +/** + * Default {@link SectionHierarchyConfiguration} used by the system. + */ +const defaultSectionHierarchyOptions = { + kind: HierarchyKind.Section, +} satisfies SectionHierarchyConfiguration; + +/** + * Default {@link DocumentHierarchyConfiguration} used by the system. + */ +const defaultDocumentHierarchyOptions = { + kind: HierarchyKind.Document, +} satisfies DocumentHierarchyConfiguration; + +/** + * Default {@link FolderHierarchyConfiguration} used by the system. + */ +const defaultFolderHierarchyOptions = { + kind: HierarchyKind.Folder, + documentPlacement: FolderDocumentPlacement.Outside, // TODO: inside +} satisfies FolderHierarchyConfiguration; + +/** + * Complete hierarchy configuration by API item kind. + * + * @public + */ +export type HierarchyConfiguration = { + /** + * Hierarchy configuration for the API item kind. + */ + readonly [Kind in Exclude< + ValidApiItemKind, + ApiItemKind.Model | ApiItemKind.EntryPoint | ApiItemKind.Package + >]: DocumentationHierarchyConfiguration; +} & { + /** + * Hierarchy configuration for the `Model` API item kind. + * + * @remarks + * Always its own document. Never introduces folder hierarchy. + * This is an important invariant, as it ensures that there is always at least one document in the output. + */ + readonly [ApiItemKind.Model]: DocumentHierarchyConfiguration; + + /** + * Hierarchy configuration for the `Package` API item kind. + * + * @remarks Must be either a folder or document hierarchy configuration. + * + * @privateRemarks + * TODO: Allow all hierarchy configurations for packages. + * There isn't a real reason to restrict this, except the way the code is currently structured. + */ + readonly [ApiItemKind.Package]: DocumentHierarchyConfiguration | FolderHierarchyConfiguration; + + /** + * Hierarchy configuration for the `EntryPoint` API item kind. + * + * @remarks + * Always its own document, adjacent to the package document. + * When a package only has a single entrypoint, this is skipped entirely and entrypoint children are rendered directly to the package document. + * + * @privateRemarks + * TODO: Allow all hierarchy configurations for packages. + * There isn't a real reason to restrict this, except the way the code is currently structured. + */ + readonly [ApiItemKind.EntryPoint]: DocumentHierarchyConfiguration; + + /** + * {@inheritDoc HierarchyOptions.getDocumentName} + */ + readonly getDocumentName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + + /** + * {@inheritDoc HierarchyOptions.getFolderName} + */ + readonly getFolderName: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; + +/** + * Input hierarchy options by API item kind. + * + * @remarks + * For each option, you may provide 1 of 2 options: + * + * - {@link HierarchyKind}: the default configuration for that kind will be used. + * + * - A complete {@link DocumentationHierarchyConfiguration} to be used in place of any default. + * + * @public + */ +export type HierarchyOptions = { + /** + * Hierarchy configuration for the API item kind. + */ + readonly [Kind in Exclude< + ValidApiItemKind, + ApiItemKind.Model | ApiItemKind.EntryPoint | ApiItemKind.Package + >]?: HierarchyKind | DocumentationHierarchyConfiguration; +} & { + /** + * Hierarchy configuration for the `Model` API item kind. + * + * @remarks + * Always its own document. Never introduces folder hierarchy. + * This is an important invariant, as it ensures that there is always at least one document in the output. + */ + readonly [ApiItemKind.Model]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + + /** + * Hierarchy configuration for the `Package` API item kind. + * + * @remarks Must be either a folder or document hierarchy configuration. + * + * @privateRemarks + * TODO: Allow all hierarchy configurations for packages. + * There isn't a real reason to restrict this, except the way the code is currently structured. + */ + readonly [ApiItemKind.Package]?: + | HierarchyKind.Document + | HierarchyKind.Folder + | DocumentHierarchyConfiguration + | FolderHierarchyConfiguration; + + /** + * Hierarchy configuration for the `EntryPoint` API item kind. + * + * @remarks + * Always its own document, adjacent to the package document. + * When a package only has a single entrypoint, this is skipped entirely and entrypoint children are rendered directly to the package document. + * + * @privateRemarks + * TODO: Allow all hierarchy configurations for packages. + * There isn't a real reason to restrict this, except the way the code is currently structured. + */ + readonly [ApiItemKind.EntryPoint]?: HierarchyKind.Document | DocumentHierarchyConfiguration; + + /** + * Generate the desired document name for the provided `ApiItem`. + * + * @remarks + * Default document name for any item configured to generate document or folder level hierarchy. + * If not specified, a system default will be used. + * + * @param apiItem - The API item for which the document name is being generated. + */ + readonly getDocumentName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; + + /** + * Generate the desired folder name for the provided `ApiItem`. + * + * @remarks + * Default folder name for any item configured to generate folder level hierarchy. + * If not specified, a system default will be used. + * + * @param apiItem - The API item for which the folder name is being generated. + */ + readonly getFolderName?: (apiItem: ApiItem, config: HierarchyConfiguration) => string; +}; + +/** + * Contains a list of default {@link DocumentationSuiteConfiguration} functions. + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace DefaultHierarchyConfigurations { + /** + * Default {@link HierarchyConfiguration.getDocumentName}. + * + * @remarks + * Uses the item's scoped and qualified API name, but is handled differently for the following items: + * + * - Model: "index" + * + * - Package: Use the unscoped package name. + */ + export function getDocumentName(apiItem: ApiItem, config: HierarchyConfiguration): string { + const kind = getApiItemKind(apiItem); + switch (kind) { + case ApiItemKind.Model: { + return "index"; + } + case ApiItemKind.Package: { + return getUnscopedPackageName(apiItem as ApiPackage); + } + default: { + // Let the system generate a unique name that accounts for folder hierarchy. + return createQualifiedDocumentNameForApiItem(apiItem, config); + } + } + } + + /** + * Default {@link HierarchyConfiguration.getFolderName}. + * + * @remarks + * Uses the item's scoped and qualified API name, but is handled differently for the following items: + * + * - Package: Use the unscoped package name. + */ + export function getFolderName(apiItem: ApiItem, config: HierarchyConfiguration): string { + const kind = getApiItemKind(apiItem); + switch (kind) { + case ApiItemKind.Package: { + return getUnscopedPackageName(apiItem as ApiPackage); + } + default: { + // Let the system generate a unique name that accounts for folder hierarchy. + return createQualifiedDocumentNameForApiItem(apiItem, config); + } + } + } +} + +/** + * Default {@link HierarchyOptions}. + */ +const defaultHierarchyOptions = { + [ApiItemKind.Model]: HierarchyKind.Document, + + // Items that introduce folder hierarchy: + [ApiItemKind.Namespace]: HierarchyKind.Folder, + [ApiItemKind.Package]: HierarchyKind.Folder, + + // Items that get their own document, but do not introduce folder hierarchy: + [ApiItemKind.Class]: HierarchyKind.Document, + [ApiItemKind.Enum]: HierarchyKind.Section, // TODO: HierarchyKind.Document + [ApiItemKind.EntryPoint]: HierarchyKind.Document, + [ApiItemKind.Interface]: HierarchyKind.Document, + [ApiItemKind.TypeAlias]: HierarchyKind.Section, // TODO: HierarchyKind.Document + + // Items that get a section under the document representing an ancestor of the API item: + [ApiItemKind.CallSignature]: HierarchyKind.Section, + [ApiItemKind.Constructor]: HierarchyKind.Section, + [ApiItemKind.ConstructSignature]: HierarchyKind.Section, + [ApiItemKind.EnumMember]: HierarchyKind.Section, + [ApiItemKind.Function]: HierarchyKind.Section, + [ApiItemKind.IndexSignature]: HierarchyKind.Section, + [ApiItemKind.Method]: HierarchyKind.Section, + [ApiItemKind.MethodSignature]: HierarchyKind.Section, + [ApiItemKind.Property]: HierarchyKind.Section, + [ApiItemKind.PropertySignature]: HierarchyKind.Section, + [ApiItemKind.Variable]: HierarchyKind.Section, +} as const; + +/** + * Maps an input option to a complete {@link DocumentationHierarchyConfiguration}. + */ +function mapHierarchyOption( + option: HierarchyKind | DocumentationHierarchyConfiguration, +): DocumentationHierarchyConfiguration { + switch (option) { + case HierarchyKind.Section: { + return defaultSectionHierarchyOptions; + } + case HierarchyKind.Document: { + return defaultDocumentHierarchyOptions; + } + case HierarchyKind.Folder: { + return defaultFolderHierarchyOptions; + } + default: { + return option; + } + } +} + +/** + * Gets a complete {@link HierarchyConfiguration} using the provided partial configuration, and filling + * in the remainder with defaults. + */ +export function getHierarchyConfigurationWithDefaults( + options?: HierarchyOptions | undefined, +): HierarchyConfiguration { + const { getDocumentName, getFolderName, ...hierarchyByItem } = options ?? {}; + + const hierarchyOptions = { + ...defaultHierarchyOptions, + ...hierarchyByItem, + }; + + const hierarchyConfigurations = Object.fromEntries( + Object.entries(hierarchyOptions).map(([key, value]) => [key, mapHierarchyOption(value)]), + ) as Omit; + + return { + getDocumentName: getDocumentName ?? DefaultHierarchyConfigurations.getDocumentName, + getFolderName: getFolderName ?? DefaultHierarchyConfigurations.getFolderName, + ...hierarchyConfigurations, + }; +} diff --git a/tools/api-markdown-documenter/src/api-item-transforms/configuration/index.ts b/tools/api-markdown-documenter/src/api-item-transforms/configuration/index.ts index 60506fdfc0cf..e6c2f209abed 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/configuration/index.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/configuration/index.ts @@ -9,15 +9,26 @@ export { type ApiItemTransformationOptions, getApiItemTransformationConfigurationWithDefaults, } from "./Configuration.js"; -export type { - // Consumers should not use this, it exists externally for documentation purposes only. - DefaultDocumentationSuiteOptions, - DocumentBoundaries, - DocumentationSuiteConfiguration, - HierarchyBoundaries, +export { + type DocumentationSuiteConfiguration, + type DefaultDocumentationSuiteConfiguration, + type DocumentationSuiteOptions, + getDocumentationSuiteConfigurationWithDefaults as getDocumentationSuiteOptionsWithDefaults, } from "./DocumentationSuite.js"; -export type { - ApiItemTransformations, - TransformApiItemWithChildren, - TransformApiItemWithoutChildren, +export { + type DocumentationHierarchyConfiguration, + type DocumentationHierarchyConfigurationBase, + type DocumentHierarchyConfiguration, + FolderDocumentPlacement, + type FolderHierarchyConfiguration, + type HierarchyConfiguration, + type HierarchyOptions, + HierarchyKind, + type SectionHierarchyConfiguration, +} from "./Hierarchy.js"; +export { + type ApiItemTransformations, + getApiItemTransformationsWithDefaults, + type TransformApiItemWithChildren, + type TransformApiItemWithoutChildren, } from "./Transformations.js"; diff --git a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/CreateSectionForApiItem.ts b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/CreateSectionForApiItem.ts index 7198e0a8fdb3..599d3f00de83 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/CreateSectionForApiItem.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/CreateSectionForApiItem.ts @@ -116,7 +116,7 @@ export function createSectionForApiItem( // Add heading to top of section only if this is being rendered to a parent item. // Document items have their headings handled specially. - return doesItemRequireOwnDocument(apiItem, config.documentBoundaries) + return doesItemRequireOwnDocument(apiItem, config.hierarchy) ? sections : [wrapInSection(sections, getHeadingForApiItem(apiItem, config))]; } diff --git a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiClass.ts b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiClass.ts index 084a7c94de1f..f00d1675978f 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiClass.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiClass.ts @@ -50,7 +50,7 @@ import { createChildDetailsSection, createMemberTables } from "../helpers/index. * * - index-signatures * - * Details (for any types not rendered to their own documents - see {@link DocumentationSuiteConfiguration.documentBoundaries}) + * Details (for any types not rendered to their own documents - see {@link ApiItemTransformationOptions.hierarchy}) * * - constructors * diff --git a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiInterface.ts b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiInterface.ts index 5c62c274d4eb..8e8b060c851e 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiInterface.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiInterface.ts @@ -39,7 +39,7 @@ import { createChildDetailsSection, createMemberTables } from "../helpers/index. * * - index-signatures * - * Details (for any types not rendered to their own documents - see {@link DocumentationSuiteConfiguration.documentBoundaries}) + * Details (for any types not rendered to their own documents - see {@link ApiItemTransformationOptions.hierarchy}) * * - constructor-signatures * diff --git a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiModuleLike.ts b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiModuleLike.ts index bc505758b32f..4561fe6319f0 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiModuleLike.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/default-implementations/TransformApiModuleLike.ts @@ -43,7 +43,7 @@ import { createChildDetailsSection, createMemberTables } from "../helpers/index. * * - namespaces * - * Details (for any types not rendered to their own documents - see {@link DocumentationSuiteConfiguration.documentBoundaries}) + * Details (for any types not rendered to their own documents - see {@link ApiItemTransformationOptions.hierarchy}) * * - interfaces * diff --git a/tools/api-markdown-documenter/src/api-item-transforms/helpers/Helpers.ts b/tools/api-markdown-documenter/src/api-item-transforms/helpers/Helpers.ts index bbc9b513012d..ee8f7f6a573b 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/helpers/Helpers.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/helpers/Helpers.ts @@ -29,6 +29,7 @@ import { } from "@microsoft/tsdoc"; import type { Heading } from "../../Heading.js"; +import type { Link } from "../../Link.js"; import type { Logger } from "../../Logging.js"; import { type DocumentationNode, @@ -55,17 +56,14 @@ import { getDeprecatedBlock, getExampleBlocks, getReturnsBlock, + getApiItemKind, type ValidApiItemKind, + getFilteredParent, } from "../../utilities/index.js"; -import { - doesItemKindRequireOwnDocument, - doesItemRequireOwnDocument, - getAncestralHierarchy, - getLinkForApiItem, -} from "../ApiItemTransformUtilities.js"; +import { doesItemKindRequireOwnDocument, getLinkForApiItem } from "../ApiItemTransformUtilities.js"; import { transformTsdocSection } from "../TsdocNodeTransforms.js"; import { getTsdocNodeTransformationOptions } from "../Utilities.js"; -import type { ApiItemTransformationConfiguration } from "../configuration/index.js"; +import { HierarchyKind, type ApiItemTransformationConfiguration } from "../configuration/index.js"; import { createParametersSummaryTable, createTypeParametersSummaryTable } from "./TableHelpers.js"; @@ -381,10 +379,10 @@ export function createExcerptSpanWithHyperlinks( * Renders a simple navigation breadcrumb. * * @remarks Displayed as a ` > `-separated list of hierarchical page links. - * 1 for each element in the provided item's ancestory for which a separate document is generated - * (see {@link DocumentBoundaries}). + * 1 for each element in the provided item's ancestry for which a separate document is generated + * (see {@link HierarchyConfiguration}). * - * @param apiItem - The API item whose ancestory will be used to generate the breadcrumb. + * @param apiItem - The API item whose ancestry will be used to generate the breadcrumb. * @param config - See {@link ApiItemTransformationConfiguration}. * * @public @@ -393,23 +391,32 @@ export function createBreadcrumbParagraph( apiItem: ApiItem, config: ApiItemTransformationConfiguration, ): ParagraphNode { - // Get ordered ancestry of document items - const ancestry = getAncestralHierarchy(apiItem, (hierarchyItem) => - doesItemRequireOwnDocument(hierarchyItem, config.documentBoundaries), - ).reverse(); // Reverse from ascending to descending order + // #region Get hierarchy of document items - const breadcrumbSeparator = new PlainTextNode(" > "); + const breadcrumbLinks: Link[] = [getLinkForApiItem(apiItem, config)]; - const links = ancestry.map((hierarchyItem) => - LinkNode.createFromPlainTextLink(getLinkForApiItem(hierarchyItem, config)), - ); + let currentItem: ApiItem | undefined = getFilteredParent(apiItem); + while (currentItem !== undefined) { + const currentItemKind = getApiItemKind(currentItem); + const currentItemHierarchy = config.hierarchy[currentItemKind]; + // Push breadcrumb entries for all files in the hierarchy. + if (currentItemHierarchy.kind !== HierarchyKind.Section) { + breadcrumbLinks.push(getLinkForApiItem(currentItem, config)); + } - // Add link for current document item - links.push(LinkNode.createFromPlainTextLink(getLinkForApiItem(apiItem, config))); + currentItem = getFilteredParent(currentItem); + } + breadcrumbLinks.reverse(); // Items are populated in ascending order, but we want them in descending order. + + // #endregion + + const renderedLinks = breadcrumbLinks.map((link) => LinkNode.createFromPlainTextLink(link)); + + const breadcrumbSeparator = new PlainTextNode(" > "); // Inject breadcrumb separator between each link const contents: DocumentationNode[] = injectSeparator( - links, + renderedLinks, breadcrumbSeparator, ); @@ -997,7 +1004,7 @@ export function createChildDetailsSection( // (i.e. it does not get rendered to its own document). // Also only render the section if it actually has contents to render (to avoid empty headings). if ( - !doesItemKindRequireOwnDocument(childItem.itemKind, config.documentBoundaries) && + !doesItemKindRequireOwnDocument(childItem.itemKind, config.hierarchy) && childItem.items.length > 0 ) { const childContents: DocumentationNode[] = []; diff --git a/tools/api-markdown-documenter/src/api-item-transforms/index.ts b/tools/api-markdown-documenter/src/api-item-transforms/index.ts index e41a85f52b11..f2e9589db7e8 100644 --- a/tools/api-markdown-documenter/src/api-item-transforms/index.ts +++ b/tools/api-markdown-documenter/src/api-item-transforms/index.ts @@ -8,7 +8,9 @@ */ export { + createQualifiedDocumentNameForApiItem, doesItemRequireOwnDocument, + doesItemKindRequireOwnDocument, filterItems, getHeadingForApiItem, getLinkForApiItem, @@ -19,11 +21,19 @@ export { type ApiItemTransformationConfigurationBase, type ApiItemTransformationOptions, type ApiItemTransformations, - type DefaultDocumentationSuiteOptions, + type DefaultDocumentationSuiteConfiguration, + type DocumentHierarchyConfiguration, type DocumentationSuiteConfiguration, - type DocumentBoundaries, + type DocumentationSuiteOptions, + FolderDocumentPlacement, + type FolderHierarchyConfiguration, getApiItemTransformationConfigurationWithDefaults, - type HierarchyBoundaries, + type DocumentationHierarchyConfiguration, + type DocumentationHierarchyConfigurationBase, + HierarchyKind, + type HierarchyConfiguration, + type HierarchyOptions, + type SectionHierarchyConfiguration, type TransformApiItemWithChildren, type TransformApiItemWithoutChildren, } from "./configuration/index.js"; diff --git a/tools/api-markdown-documenter/src/index.ts b/tools/api-markdown-documenter/src/index.ts index a359780dc5c1..18ff012f5d9b 100644 --- a/tools/api-markdown-documenter/src/index.ts +++ b/tools/api-markdown-documenter/src/index.ts @@ -18,12 +18,20 @@ export { type ApiItemTransformationConfigurationBase, type ApiItemTransformationOptions, type ApiItemTransformations, - type DefaultDocumentationSuiteOptions, + type DefaultDocumentationSuiteConfiguration, + type DocumentationHierarchyConfiguration, + type DocumentationHierarchyConfigurationBase, type DocumentationSuiteConfiguration, - type DocumentBoundaries, + type DocumentationSuiteOptions, + type DocumentHierarchyConfiguration, + FolderDocumentPlacement, + type FolderHierarchyConfiguration, // TODO: remove this once utility APIs can be called with partial configs. getApiItemTransformationConfigurationWithDefaults, - type HierarchyBoundaries, + HierarchyKind, + type HierarchyConfiguration, + type HierarchyOptions, + type SectionHierarchyConfiguration, type TransformApiItemWithChildren, type TransformApiItemWithoutChildren, transformApiModel, @@ -68,13 +76,13 @@ export { type Logger, verboseConsoleLogger, } from "./Logging.js"; -export { - type ApiFunctionLike, - type ApiMemberKind, - type ApiModifier, - type ApiModuleLike, - type ApiSignatureLike, - type ValidApiItemKind, +export type { + ApiFunctionLike, + ApiMemberKind, + ApiModifier, + ApiModuleLike, + ApiSignatureLike, + ValidApiItemKind, } from "./utilities/index.js"; // #region Scoped exports diff --git a/tools/api-markdown-documenter/src/test/EndToEndTestUtilities.ts b/tools/api-markdown-documenter/src/test/EndToEndTestUtilities.ts new file mode 100644 index 000000000000..521cd8635cf9 --- /dev/null +++ b/tools/api-markdown-documenter/src/test/EndToEndTestUtilities.ts @@ -0,0 +1,183 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import Path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { ApiItemKind } from "@microsoft/api-extractor-model"; +import { FileSystem } from "@rushstack/node-core-library"; +import { expect } from "chai"; +import { compare } from "dir-compare"; + +import { + FolderDocumentPlacement, + HierarchyKind, + type FolderHierarchyConfiguration, + type HierarchyOptions, +} from "../index.js"; + +const dirname = Path.dirname(fileURLToPath(import.meta.url)); + +/** + * Temp directory under which all tests that generate files will output their contents. + */ +export const testTemporaryDirectoryPath = Path.resolve(dirname, "test_temp"); + +/** + * Snapshot directory to which generated test data will be copied. + * @remarks Relative to lib/test + */ +export const snapshotsDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "snapshots"); + +/** + * Directory containing the end-to-end test models. + * @remarks Relative to lib/test + */ +export const testDataDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "test-data"); + +/** + * Test hierarchy configurations + * + * @privateRemarks TODO: Formalize and export some of these as pre-canned solutions? + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace HierarchyConfigurations { + const outsideFolderConfig: FolderHierarchyConfiguration = { + kind: HierarchyKind.Folder, + documentPlacement: FolderDocumentPlacement.Outside, + }; + + /** + * "Flat" hierarchy: Packages get their own documents, and all descendent API items are rendered as sections under that document. + * @remarks Results in a small number of documents, but can lead to relatively large documents. + */ + export const flat: HierarchyOptions = { + [ApiItemKind.Package]: HierarchyKind.Document, + + [ApiItemKind.CallSignature]: HierarchyKind.Section, + [ApiItemKind.Class]: HierarchyKind.Section, + [ApiItemKind.Constructor]: HierarchyKind.Section, + [ApiItemKind.ConstructSignature]: HierarchyKind.Section, + [ApiItemKind.Enum]: HierarchyKind.Section, + [ApiItemKind.EnumMember]: HierarchyKind.Section, + [ApiItemKind.Function]: HierarchyKind.Section, + [ApiItemKind.IndexSignature]: HierarchyKind.Section, + [ApiItemKind.Interface]: HierarchyKind.Section, + [ApiItemKind.Method]: HierarchyKind.Section, + [ApiItemKind.MethodSignature]: HierarchyKind.Section, + [ApiItemKind.Namespace]: HierarchyKind.Section, + [ApiItemKind.Property]: HierarchyKind.Section, + [ApiItemKind.PropertySignature]: HierarchyKind.Section, + [ApiItemKind.TypeAlias]: HierarchyKind.Section, + [ApiItemKind.Variable]: HierarchyKind.Section, + }; + + /** + * "Sparse" hierarchy: Packages yield folder hierarchy, and each descendent item gets its own document under that folder. + * @remarks Leads to many documents, but each document is likely to be relatively small. + */ + export const sparse: HierarchyOptions = { + [ApiItemKind.Package]: outsideFolderConfig, + + [ApiItemKind.CallSignature]: HierarchyKind.Document, + [ApiItemKind.Class]: HierarchyKind.Document, + [ApiItemKind.Constructor]: HierarchyKind.Document, + [ApiItemKind.ConstructSignature]: HierarchyKind.Document, + [ApiItemKind.Enum]: HierarchyKind.Document, + [ApiItemKind.EnumMember]: HierarchyKind.Document, + [ApiItemKind.Function]: HierarchyKind.Document, + [ApiItemKind.IndexSignature]: HierarchyKind.Document, + [ApiItemKind.Interface]: HierarchyKind.Document, + [ApiItemKind.Method]: HierarchyKind.Document, + [ApiItemKind.MethodSignature]: HierarchyKind.Document, + [ApiItemKind.Namespace]: HierarchyKind.Document, + [ApiItemKind.Property]: HierarchyKind.Document, + [ApiItemKind.PropertySignature]: HierarchyKind.Document, + [ApiItemKind.TypeAlias]: HierarchyKind.Document, + [ApiItemKind.Variable]: HierarchyKind.Document, + }; + + // TODO + // const insideFolderOptions: FolderHierarchyConfiguration = { + // kind: HierarchyKind.Folder, + // documentPlacement: FolderDocumentPlacement.Inside, + // }; + // /** + // * "Deep" hierarchy: All "parent" API items generate hierarchy. All other items are rendered as documents under their parent hierarchy. + // * @remarks Leads to many documents, but each document is likely to be relatively small. + // */ + // export const deep: HierarchyOptions = { + // // Items that introduce folder hierarchy: + // [ApiItemKind.Namespace]: insideFolderOptions, + // [ApiItemKind.Package]: insideFolderOptions, + // [ApiItemKind.Class]: insideFolderOptions, + // [ApiItemKind.Enum]: insideFolderOptions, + // [ApiItemKind.Interface]: insideFolderOptions, + // [ApiItemKind.TypeAlias]: insideFolderOptions, + + // // Items that get their own document, but do not introduce folder hierarchy: + // [ApiItemKind.CallSignature]: HierarchyKind.Document, + // [ApiItemKind.Constructor]: HierarchyKind.Document, + // [ApiItemKind.ConstructSignature]: HierarchyKind.Document, + // [ApiItemKind.EnumMember]: HierarchyKind.Document, + // [ApiItemKind.Function]: HierarchyKind.Document, + // [ApiItemKind.IndexSignature]: HierarchyKind.Document, + // [ApiItemKind.Method]: HierarchyKind.Document, + // [ApiItemKind.MethodSignature]: HierarchyKind.Document, + // [ApiItemKind.Property]: HierarchyKind.Document, + // [ApiItemKind.PropertySignature]: HierarchyKind.Document, + // [ApiItemKind.Variable]: HierarchyKind.Document, + + // getDocumentName: (apiItem, config): string => { + // switch (apiItem.kind) { + // case ApiItemKind.Model: + // case ApiItemKind.Package: + // case ApiItemKind.Namespace: + // case ApiItemKind.Class: + // case ApiItemKind.Enum: + // case ApiItemKind.Interface: + // case ApiItemKind.TypeAlias: { + // return "index"; + // } + // default: { + // // Let the system generate a unique name that accounts for folder hierarchy. + // return ApiItemUtilities.createQualifiedDocumentNameForApiItem(apiItem, config); + // } + // } + // }, + // }; +} + +/** + * Compares "expected" to "actual" documentation test suite output. + * Succeeds the Mocha test if the directory contents match. + * Otherwise, fails the test and copies the new output to the snapshot directory so the developer can view the diff + * in git, and check in the changes if appropriate. + * + * @param snapshotDirectoryPath - Resolved path to the directory containing the checked-in assets for the test. + * Represents the "expected" test output. + * + * @param temporaryDirectoryPath - Resolved path to the directory containing the freshly generated test output. + * Represents the "actual" test output. + */ +export async function compareDocumentationSuiteSnapshot( + snapshotDirectoryPath: string, + temporaryDirectoryPath: string, +): Promise { + // Verify against expected contents + const result = await compare(temporaryDirectoryPath, snapshotDirectoryPath, { + compareContent: true, + }); + + if (!result.same) { + await FileSystem.ensureEmptyFolderAsync(snapshotDirectoryPath); + await FileSystem.copyFilesAsync({ + sourcePath: temporaryDirectoryPath, + destinationPath: snapshotDirectoryPath, + }); + + expect.fail(`Snapshot test encountered ${result.differencesFiles} file diffs.`); + } +} diff --git a/tools/api-markdown-documenter/src/test/EndToEndTests.ts b/tools/api-markdown-documenter/src/test/EndToEndTests.ts deleted file mode 100644 index 4fccb6321cfc..000000000000 --- a/tools/api-markdown-documenter/src/test/EndToEndTests.ts +++ /dev/null @@ -1,226 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import Path from "node:path"; - -import type { ApiModel } from "@microsoft/api-extractor-model"; -import { FileSystem } from "@rushstack/node-core-library"; -import { expect } from "chai"; -import { compare } from "dir-compare"; -import type { Suite } from "mocha"; - -import { loadModel } from "../LoadModel.js"; -import { - type ApiItemTransformationOptions, - checkForDuplicateDocumentPaths, - transformApiModel, -} from "../api-item-transforms/index.js"; -import type { DocumentNode } from "../documentation-domain/index.js"; - -/** - * End-to-end snapshot test configuration. - * - * @remarks Generates a test suite with a test for each combination of API Model and test configuration. - */ -export interface EndToEndSuiteConfig { - /** - * Name of the outer test suite. - */ - readonly suiteName: string; - - /** - * Path to the directory where all suite test output will be written for comparison against checked-in snapshots. - * - * @remarks - * Individual tests' output will be written to `/<{@link ApiModelTestOptions.modelName}>/<{@link ApiItemTransformationTestOptions.configName}>/<{@link RenderTestOptions.configName}>`. - */ - readonly temporaryOutputDirectoryPath: string; - - /** - * Path to the directory containing the checked-in snapshots for comparison in this suite. - * - * @remarks - * Individual tests' output will be written to `/<{@link ApiModelTestOptions.modelName}>/<{@link ApiItemTransformationTestOptions.configName}>/<{@link RenderTestOptions.configName}>`. - */ - readonly snapshotsDirectoryPath: string; - - /** - * The end-to-end test scenario to run against the API model. - * Writes the output to the specified directory for snapshot comparison. - */ - render( - document: DocumentNode, - renderConfig: TRenderConfig, - outputDirectoryPath: string, - ): Promise; - - /** - * The models to test. - */ - readonly apiModels: readonly ApiModelTestOptions[]; - - /** - * Test configurations to run against each API Model. - */ - readonly testConfigs: readonly EndToEndTestConfig[]; -} - -/** - * API Model test options for a test. - */ -export interface ApiModelTestOptions { - /** - * Name of the API Model being tested. - */ - readonly modelName: string; - - /** - * Path to the directory containing the API Model. - */ - readonly directoryPath: string; -} - -/** - * API Item transformation options for a test. - */ -export interface EndToEndTestConfig { - /** - * Test name - */ - readonly testName: string; - - /** - * The transformation configuration to use. - */ - readonly transformConfig: Omit; - - /** - * Render configuration. - */ - readonly renderConfig: TRenderConfig; -} - -/** - * Generates a test suite that performs end-to-end tests for each test - * configuration x API Model combination. - * - * @remarks - * The generated test suite will include the following checks: - * - * - Basic smoke-test validation of the API Item transformation step, ensuring unique document paths. - * - * - Snapshot test comparing the final rendered output against checked-in snapshots. - */ -export function endToEndTests( - suiteConfig: EndToEndSuiteConfig, -): Suite { - return describe(suiteConfig.suiteName, () => { - for (const apiModelTestConfig of suiteConfig.apiModels) { - const { modelName, directoryPath: modelDirectoryPath } = apiModelTestConfig; - describe(modelName, () => { - let apiModel: ApiModel; - before(async () => { - apiModel = await loadModel({ modelDirectoryPath }); - }); - - for (const testConfig of suiteConfig.testConfigs) { - const { - testName, - transformConfig: partialTransformConfig, - renderConfig, - } = testConfig; - - const testOutputPath = Path.join(modelName, testName); - const temporaryDirectoryPath = Path.resolve( - suiteConfig.temporaryOutputDirectoryPath, - testOutputPath, - ); - const snapshotDirectoryPath = Path.resolve( - suiteConfig.snapshotsDirectoryPath, - testOutputPath, - ); - - describe(testName, () => { - let apiItemTransformConfig: ApiItemTransformationOptions; - before(async () => { - apiItemTransformConfig = { - ...partialTransformConfig, - apiModel, - }; - }); - - // Run a sanity check to ensure that the suite did not generate multiple documents with the same - // output file path. This either indicates a bug in the system, or an bad configuration. - it("Ensure no duplicate file paths", () => { - const documents = transformApiModel(apiItemTransformConfig); - - // Will throw if any duplicates are found. - checkForDuplicateDocumentPaths(documents); - }); - - // Perform actual output snapshot comparison test against checked-in test collateral. - it("Snapshot test", async () => { - // Ensure the output temp and snapshots directories exists (will create an empty ones if they don't). - await FileSystem.ensureFolderAsync(temporaryDirectoryPath); - await FileSystem.ensureFolderAsync(snapshotDirectoryPath); - - // Clear any existing test_temp data - await FileSystem.ensureEmptyFolderAsync(temporaryDirectoryPath); - - const documents = transformApiModel(apiItemTransformConfig); - - await Promise.all( - documents.map(async (document) => - suiteConfig.render( - document, - renderConfig, - temporaryDirectoryPath, - ), - ), - ); - - await compareDocumentationSuiteSnapshot( - snapshotDirectoryPath, - temporaryDirectoryPath, - ); - }); - }); - } - }); - } - }); -} - -/** - * Compares "expected" to "actual" documentation test suite output. - * Succeeds the Mocha test if the directory contents match. - * Otherwise, fails the test and copies the new output to the snapshot directory so the developer can view the diff - * in git, and check in the changes if appropriate. - * - * @param snapshotDirectoryPath - Resolved path to the directory containing the checked-in assets for the test. - * Represents the "expected" test output. - * - * @param temporaryDirectoryPath - Resolved path to the directory containing the freshly generated test output. - * Represents the "actual" test output. - */ -async function compareDocumentationSuiteSnapshot( - snapshotDirectoryPath: string, - temporaryDirectoryPath: string, -): Promise { - // Verify against expected contents - const result = await compare(temporaryDirectoryPath, snapshotDirectoryPath, { - compareContent: true, - }); - - if (!result.same) { - await FileSystem.ensureEmptyFolderAsync(snapshotDirectoryPath); - await FileSystem.copyFilesAsync({ - sourcePath: temporaryDirectoryPath, - destinationPath: snapshotDirectoryPath, - }); - - expect.fail(`Snapshot test encountered ${result.differencesFiles} file diffs.`); - } -} diff --git a/tools/api-markdown-documenter/src/test/HtmlEndToEnd.test.ts b/tools/api-markdown-documenter/src/test/HtmlEndToEnd.test.ts index 3131207f7587..91105e623114 100644 --- a/tools/api-markdown-documenter/src/test/HtmlEndToEnd.test.ts +++ b/tools/api-markdown-documenter/src/test/HtmlEndToEnd.test.ts @@ -3,141 +3,114 @@ * Licensed under the MIT License. */ -import * as Path from "node:path"; -import { fileURLToPath } from "node:url"; +import Path from "node:path"; -import { ApiItemKind, ReleaseTag } from "@microsoft/api-extractor-model"; -import { FileSystem, NewlineKind } from "@rushstack/node-core-library"; +import { ReleaseTag, type ApiModel } from "@microsoft/api-extractor-model"; -import type { DocumentNode } from "../documentation-domain/index.js"; -import { - type RenderDocumentAsHtmlConfiguration, - renderDocumentAsHtml, -} from "../renderers/index.js"; +import { HtmlRenderer, loadModel } from "../index.js"; import { - endToEndTests, - type ApiModelTestOptions, - type EndToEndTestConfig, -} from "./EndToEndTests.js"; - -const dirname = Path.dirname(fileURLToPath(import.meta.url)); + compareDocumentationSuiteSnapshot, + HierarchyConfigurations, + snapshotsDirectoryPath as snapshotsDirectoryPathBase, + testDataDirectoryPath, + testTemporaryDirectoryPath as testTemporaryDirectoryPathBase, +} from "./EndToEndTestUtilities.js"; /** * Temp directory under which all tests that generate files will output their contents. */ -const testTemporaryDirectoryPath = Path.resolve(dirname, "test_temp", "html"); +const testTemporaryDirectoryPath = Path.resolve(testTemporaryDirectoryPathBase, "html"); /** * Snapshot directory to which generated test data will be copied. * Relative to lib/test */ -const snapshotsDirectoryPath = Path.resolve( - dirname, - "..", - "..", - "src", - "test", - "snapshots", - "html", -); - -// Relative to lib/test -const testDataDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "test-data"); - -const apiModels: ApiModelTestOptions[] = [ - { - modelName: "simple-suite-test", - directoryPath: Path.resolve(testDataDirectoryPath, "simple-suite-test"), - }, - // TODO: add other models -]; - -const testConfigs: EndToEndTestConfig[] = [ - /** - * A sample "flat" configuration, which renders every item kind under a package to the package parent document. - */ - { - testName: "default-config", - transformConfig: { +const snapshotsDirectoryPath = Path.resolve(snapshotsDirectoryPathBase, "html"); + +const apiModels: string[] = ["simple-suite-test"]; + +const testConfigs = new Map< + string, + Omit +>([ + [ + "default-config", + { uriRoot: ".", }, - renderConfig: {}, - }, - - /** - * A sample "flat" configuration, which renders every item kind under a package to the package parent document. - */ - { - testName: "flat-config", - transformConfig: { + ], + + // A sample "flat" configuration, which renders every item kind under a package to the package parent document. + [ + "flat-config", + { uriRoot: "docs", includeBreadcrumb: true, includeTopLevelDocumentHeading: false, - documentBoundaries: [], // Render everything to package documents - hierarchyBoundaries: [], // No additional hierarchy beyond the package level + hierarchy: HierarchyConfigurations.flat, minimumReleaseLevel: ReleaseTag.Beta, // Only include `@public` and `beta` items in the docs suite }, - renderConfig: {}, - }, - - /** - * A sample "sparse" configuration, which renders every item kind to its own document. - */ - { - testName: "sparse-config", - transformConfig: { + ], + + // A sample "sparse" configuration, which renders every item kind to its own document. + [ + "sparse-config", + { uriRoot: "docs", includeBreadcrumb: false, includeTopLevelDocumentHeading: true, - // Render everything to its own document - documentBoundaries: [ - ApiItemKind.CallSignature, - ApiItemKind.Class, - ApiItemKind.ConstructSignature, - ApiItemKind.Constructor, - ApiItemKind.Enum, - ApiItemKind.EnumMember, - ApiItemKind.Function, - ApiItemKind.IndexSignature, - ApiItemKind.Interface, - ApiItemKind.Method, - ApiItemKind.MethodSignature, - ApiItemKind.Namespace, - ApiItemKind.Property, - ApiItemKind.PropertySignature, - ApiItemKind.TypeAlias, - ApiItemKind.Variable, - ], - hierarchyBoundaries: [], // No additional hierarchy beyond the package level + hierarchy: HierarchyConfigurations.sparse, minimumReleaseLevel: ReleaseTag.Public, // Only include `@public` items in the docs suite skipPackage: (apiPackage) => apiPackage.name === "test-suite-b", // Skip test-suite-b package - }, - renderConfig: { startingHeadingLevel: 2, }, - }, -]; - -async function renderDocumentToFile( - document: DocumentNode, - renderConfig: RenderDocumentAsHtmlConfiguration, - outputDirectoryPath: string, -): Promise { - const renderedDocument = renderDocumentAsHtml(document, renderConfig); - - const filePath = Path.join(outputDirectoryPath, `${document.documentPath}.html`); - await FileSystem.writeFileAsync(filePath, renderedDocument, { - convertLineEndings: NewlineKind.Lf, - ensureFolderExists: true, - }); -} - -endToEndTests({ - suiteName: "Markdown End-to-End Tests", - temporaryOutputDirectoryPath: testTemporaryDirectoryPath, - snapshotsDirectoryPath, - render: renderDocumentToFile, - apiModels, - testConfigs, + ], + + // TODO + // // A sample "deep" configuration. + // // All "parent" API items generate hierarchy. + // // All other items are rendered as documents under their parent hierarchy. + // [ + // "deep-config", + // { + // uriRoot: ".", + // hierarchy: HierarchyConfigurations.deep, + // }, + // ], +]); + +describe("HTML end-to-end tests", () => { + for (const modelName of apiModels) { + // Input directory for the model + const modelDirectoryPath = Path.join(testDataDirectoryPath, modelName); + + describe(`API model: ${modelName}`, () => { + let apiModel: ApiModel; + before(async () => { + apiModel = await loadModel({ modelDirectoryPath }); + }); + + for (const [configName, inputConfig] of testConfigs) { + const temporaryOutputPath = Path.join( + testTemporaryDirectoryPath, + modelName, + configName, + ); + const snapshotPath = Path.join(snapshotsDirectoryPath, modelName, configName); + + it(configName, async () => { + const options: HtmlRenderer.RenderApiModelOptions = { + ...inputConfig, + apiModel, + outputDirectoryPath: temporaryOutputPath, + }; + + await HtmlRenderer.renderApiModel(options); + + await compareDocumentationSuiteSnapshot(snapshotPath, temporaryOutputPath); + }); + } + }); + } }); diff --git a/tools/api-markdown-documenter/src/test/MarkdownEndToEnd.test.ts b/tools/api-markdown-documenter/src/test/MarkdownEndToEnd.test.ts index 40d9dfda0a7d..c11dafa4a1d8 100644 --- a/tools/api-markdown-documenter/src/test/MarkdownEndToEnd.test.ts +++ b/tools/api-markdown-documenter/src/test/MarkdownEndToEnd.test.ts @@ -3,138 +3,114 @@ * Licensed under the MIT License. */ -import * as Path from "node:path"; -import { fileURLToPath } from "node:url"; +import Path from "node:path"; -import { ApiItemKind, ReleaseTag } from "@microsoft/api-extractor-model"; -import { FileSystem, NewlineKind } from "@rushstack/node-core-library"; +import { ReleaseTag, type ApiModel } from "@microsoft/api-extractor-model"; -import type { DocumentNode } from "../documentation-domain/index.js"; -import { type MarkdownRenderConfiguration, renderDocumentAsMarkdown } from "../renderers/index.js"; +import { loadModel, MarkdownRenderer } from "../index.js"; import { - endToEndTests, - type ApiModelTestOptions, - type EndToEndTestConfig, -} from "./EndToEndTests.js"; - -const dirname = Path.dirname(fileURLToPath(import.meta.url)); + compareDocumentationSuiteSnapshot, + HierarchyConfigurations, + snapshotsDirectoryPath as snapshotsDirectoryPathBase, + testDataDirectoryPath, + testTemporaryDirectoryPath as testTemporaryDirectoryPathBase, +} from "./EndToEndTestUtilities.js"; /** * Temp directory under which all tests that generate files will output their contents. */ -const testTemporaryDirectoryPath = Path.resolve(dirname, "test_temp", "markdown"); +const testTemporaryDirectoryPath = Path.resolve(testTemporaryDirectoryPathBase, "markdown"); /** * Snapshot directory to which generated test data will be copied. * Relative to lib/test */ -const snapshotsDirectoryPath = Path.resolve( - dirname, - "..", - "..", - "src", - "test", - "snapshots", - "markdown", -); - -// Relative to lib/test -const testDataDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "test-data"); - -const apiModels: ApiModelTestOptions[] = [ - { - modelName: "simple-suite-test", - directoryPath: Path.resolve(testDataDirectoryPath, "simple-suite-test"), - }, - // TODO: add other models -]; - -const testConfigs: EndToEndTestConfig[] = [ - /** - * A sample "flat" configuration, which renders every item kind under a package to the package parent document. - */ - { - testName: "default-config", - transformConfig: { +const snapshotsDirectoryPath = Path.resolve(snapshotsDirectoryPathBase, "markdown"); + +const apiModels: string[] = ["simple-suite-test"]; + +const testConfigs = new Map< + string, + Omit +>([ + [ + "default-config", + { uriRoot: ".", }, - renderConfig: {}, - }, - - /** - * A sample "flat" configuration, which renders every item kind under a package to the package parent document. - */ - { - testName: "flat-config", - transformConfig: { + ], + + // A sample "flat" configuration, which renders every item kind under a package to the package parent document. + [ + "flat-config", + { uriRoot: "docs", includeBreadcrumb: true, includeTopLevelDocumentHeading: false, - documentBoundaries: [], // Render everything to package documents - hierarchyBoundaries: [], // No additional hierarchy beyond the package level + hierarchy: HierarchyConfigurations.flat, minimumReleaseLevel: ReleaseTag.Beta, // Only include `@public` and `beta` items in the docs suite }, - renderConfig: {}, - }, - - /** - * A sample "sparse" configuration, which renders every item kind to its own document. - */ - { - testName: "sparse-config", - transformConfig: { + ], + + // A sample "sparse" configuration, which renders every item kind to its own document. + [ + "sparse-config", + { uriRoot: "docs", includeBreadcrumb: false, includeTopLevelDocumentHeading: true, - // Render everything to its own document - documentBoundaries: [ - ApiItemKind.CallSignature, - ApiItemKind.Class, - ApiItemKind.ConstructSignature, - ApiItemKind.Constructor, - ApiItemKind.Enum, - ApiItemKind.EnumMember, - ApiItemKind.Function, - ApiItemKind.IndexSignature, - ApiItemKind.Interface, - ApiItemKind.Method, - ApiItemKind.MethodSignature, - ApiItemKind.Namespace, - ApiItemKind.Property, - ApiItemKind.PropertySignature, - ApiItemKind.TypeAlias, - ApiItemKind.Variable, - ], - hierarchyBoundaries: [], // No additional hierarchy beyond the package level + hierarchy: HierarchyConfigurations.sparse, minimumReleaseLevel: ReleaseTag.Public, // Only include `@public` items in the docs suite skipPackage: (apiPackage) => apiPackage.name === "test-suite-b", // Skip test-suite-b package - }, - renderConfig: { startingHeadingLevel: 2, }, - }, -]; - -async function renderDocumentToFile( - document: DocumentNode, - renderConfig: MarkdownRenderConfiguration, - outputDirectoryPath: string, -): Promise { - const renderedDocument = renderDocumentAsMarkdown(document, renderConfig); - - const filePath = Path.join(outputDirectoryPath, `${document.documentPath}.md`); - await FileSystem.writeFileAsync(filePath, renderedDocument, { - convertLineEndings: NewlineKind.Lf, - ensureFolderExists: true, - }); -} - -endToEndTests({ - suiteName: "Markdown End-to-End Tests", - temporaryOutputDirectoryPath: testTemporaryDirectoryPath, - snapshotsDirectoryPath, - render: renderDocumentToFile, - apiModels, - testConfigs, + ], + + // TODO + // // A sample "deep" configuration. + // // All "parent" API items generate hierarchy. + // // All other items are rendered as documents under their parent hierarchy. + // [ + // "deep-config", + // { + // uriRoot: ".", + // hierarchy: HierarchyConfigurations.deep, + // }, + // ], +]); + +describe("Markdown end-to-end tests", () => { + for (const modelName of apiModels) { + // Input directory for the model + const modelDirectoryPath = Path.join(testDataDirectoryPath, modelName); + + describe(`API model: ${modelName}`, () => { + let apiModel: ApiModel; + before(async () => { + apiModel = await loadModel({ modelDirectoryPath }); + }); + + for (const [configName, inputConfig] of testConfigs) { + const temporaryOutputPath = Path.join( + testTemporaryDirectoryPath, + modelName, + configName, + ); + const snapshotPath = Path.join(snapshotsDirectoryPath, modelName, configName); + + it(configName, async () => { + const options: MarkdownRenderer.RenderApiModelOptions = { + ...inputConfig, + apiModel, + outputDirectoryPath: temporaryOutputPath, + }; + + await MarkdownRenderer.renderApiModel(options); + + await compareDocumentationSuiteSnapshot(snapshotPath, temporaryOutputPath); + }); + } + }); + } }); diff --git a/tools/api-markdown-documenter/src/test/TransformApiModelEndToEnd.test.ts b/tools/api-markdown-documenter/src/test/TransformApiModelEndToEnd.test.ts new file mode 100644 index 000000000000..6b4eec8d3dbe --- /dev/null +++ b/tools/api-markdown-documenter/src/test/TransformApiModelEndToEnd.test.ts @@ -0,0 +1,79 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import Path from "node:path"; + +import { ReleaseTag, type ApiModel } from "@microsoft/api-extractor-model"; + +import { checkForDuplicateDocumentPaths } from "../api-item-transforms/index.js"; +import { loadModel, transformApiModel, type ApiItemTransformationOptions } from "../index.js"; + +import { HierarchyConfigurations, testDataDirectoryPath } from "./EndToEndTestUtilities.js"; + +const apiModels: string[] = ["simple-suite-test"]; + +const testConfigs = new Map>([ + [ + "default-config", + { + uriRoot: ".", + }, + ], + + // A sample "flat" configuration, which renders every item kind under a package to the package parent document. + [ + "flat-config", + { + uriRoot: "docs", + includeBreadcrumb: true, + includeTopLevelDocumentHeading: false, + hierarchy: HierarchyConfigurations.sparse, + minimumReleaseLevel: ReleaseTag.Beta, // Only include `@public` and `beta` items in the docs suite + }, + ], + + // A sample "sparse" configuration, which renders every item kind to its own document. + [ + "sparse-config", + { + uriRoot: "docs", + includeBreadcrumb: false, + includeTopLevelDocumentHeading: true, + hierarchy: HierarchyConfigurations.sparse, + minimumReleaseLevel: ReleaseTag.Public, // Only include `@public` items in the docs suite + skipPackage: (apiPackage) => apiPackage.name === "test-suite-b", // Skip test-suite-b package + }, + ], +]); + +describe("API model transformation end-to-end tests", () => { + for (const modelName of apiModels) { + // Input directory for the model + const modelDirectoryPath = Path.join(testDataDirectoryPath, modelName); + + describe(`API model: ${modelName}`, () => { + let apiModel: ApiModel; + before(async () => { + apiModel = await loadModel({ modelDirectoryPath }); + }); + + describe("Ensure no duplicate document paths", () => { + for (const [configName, inputConfig] of testConfigs) { + it(configName, async () => { + const config: ApiItemTransformationOptions = { + ...inputConfig, + apiModel, + }; + + const documents = transformApiModel(config); + + // Will throw if any duplicates are found. + checkForDuplicateDocumentPaths(documents); + }); + } + }); + }); + } +}); diff --git a/tools/api-markdown-documenter/src/utilities/TypeUtilities.ts b/tools/api-markdown-documenter/src/utilities/TypeUtilities.ts new file mode 100644 index 000000000000..c6d3db8c28b7 --- /dev/null +++ b/tools/api-markdown-documenter/src/utilities/TypeUtilities.ts @@ -0,0 +1,29 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Type that removes `readonly` from fields. + */ +export type Mutable = { -readonly [P in keyof T]: T[P] }; + +/** + * Represents a value that can be either a direct value of type `T` or a function that returns a value of type `T` given some parameters. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ValueOrDerived = T | ((..._arguments: TArguments) => T); + +/** + * Returns the value of a `ValueOrDerived` object, either by directly returning the value or by calling the function with the provided arguments. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getValueOrDerived( + valueOrDerived: ValueOrDerived, + ..._arguments: TArguments +): T { + if (typeof valueOrDerived === "function") { + return (valueOrDerived as (..._arguments: TArguments) => T)(..._arguments); + } + return valueOrDerived; +} diff --git a/tools/api-markdown-documenter/src/utilities/index.ts b/tools/api-markdown-documenter/src/utilities/index.ts index bec47e1e4f08..2350c4f9e4fd 100644 --- a/tools/api-markdown-documenter/src/utilities/index.ts +++ b/tools/api-markdown-documenter/src/utilities/index.ts @@ -4,6 +4,8 @@ */ // All of the utilities here are meant to be used outside of this directory. -// eslint-disable-next-line no-restricted-syntax +/* eslint-disable no-restricted-syntax */ + export * from "./ApiItemUtilities.js"; -export { injectSeparator } from "./ArrayUtilities.js"; +export * from "./ArrayUtilities.js"; +export * from "./TypeUtilities.js";