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..ff52f52f0024 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: THierarchyKind; +} + // @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,12 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export type DocumentHierarchyConfiguration = DocumentationHierarchyConfigurationBase; // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +370,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 +388,17 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export type FolderHierarchyConfiguration = DocumentationHierarchyConfigurationBase & { + readonly documentPlacement: FolderDocumentPlacement; +}; + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +468,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 +750,9 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export type SectionHierarchyConfiguration = DocumentationHierarchyConfigurationBase; + // @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..220ffc66e011 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: THierarchyKind; +} + // @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,12 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export type DocumentHierarchyConfiguration = DocumentationHierarchyConfigurationBase; // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +370,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 +388,17 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export type FolderHierarchyConfiguration = DocumentationHierarchyConfigurationBase & { + readonly documentPlacement: FolderDocumentPlacement; +}; + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +468,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 +736,9 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export type SectionHierarchyConfiguration = DocumentationHierarchyConfigurationBase; + // @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..db51e0b53358 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: THierarchyKind; +} + // @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,12 @@ export interface DocumentationSuiteConfiguration { } // @public -export type DocumentBoundaries = ApiMemberKind[]; +export type DocumentationSuiteOptions = Omit, "hierarchy"> & { + readonly hierarchy?: HierarchyOptions; +}; + +// @public @sealed +export type DocumentHierarchyConfiguration = DocumentationHierarchyConfigurationBase; // @public export class DocumentNode implements Parent, DocumentNodeProps { @@ -359,9 +370,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 +388,17 @@ export interface FileSystemConfiguration { // @public function filterItems(apiItems: readonly ApiItem[], config: ApiItemTransformationConfiguration): ApiItem[]; +// @public +export enum FolderDocumentPlacement { + Inside = "Inside", + Outside = "Outside" +} + +// @public @sealed +export type FolderHierarchyConfiguration = DocumentationHierarchyConfigurationBase & { + readonly documentPlacement: FolderDocumentPlacement; +}; + // @public export function getApiItemTransformationConfigurationWithDefaults(options: ApiItemTransformationOptions): ApiItemTransformationConfiguration; @@ -449,7 +468,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 +714,9 @@ function renderNode(node: DocumentationNode, writer: DocumentWriter, context: Ma // @public function renderNodes(children: DocumentationNode[], writer: DocumentWriter, childContext: MarkdownRenderContext): void; +// @public @sealed +export type SectionHierarchyConfiguration = DocumentationHierarchyConfigurationBase; + // @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..09e536395943 --- /dev/null +++ b/tools/api-markdown-documenter/src/api-item-transforms/configuration/Hierarchy.ts @@ -0,0 +1,407 @@ +/*! + * 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: THierarchyKind; +} + +/** + * The corresponding API item will be placed in a section under the document representing an ancestor of the API item. + * + * @sealed + * @public + */ +export type SectionHierarchyConfiguration = + DocumentationHierarchyConfigurationBase; + +/** + * The corresponding API item will get its own document, in the folder for an ancestor of the API item. + * + * @sealed + * @public + */ +export type DocumentHierarchyConfiguration = + DocumentationHierarchyConfigurationBase; + +/** + * 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 type FolderHierarchyConfiguration = + DocumentationHierarchyConfigurationBase & { + /** + * 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";