Skip to content

Commit

Permalink
improvement(api-markdown-documenter): Check for duplicate document pa…
Browse files Browse the repository at this point in the history
…ths in transformation output and log warning for any encountered (#23514)

It is possible to configure the system to generate multiple output
documents targeting the same file path. To help users detect such cases,
logic has been added to check transformation output for any documents
with duplicate paths. If any are found, a warning is logged.
  • Loading branch information
Josmithr authored Jan 8, 2025
1 parent 1047ebb commit 172933c
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { DocumentNode, SectionNode } from "../documentation-domain/index.js

import { doesItemRequireOwnDocument, shouldItemBeIncluded } from "./ApiItemTransformUtilities.js";
import { apiItemToDocument, apiItemToSections } from "./TransformApiItem.js";
import { createDocument } from "./Utilities.js";
import { checkForDuplicateDocumentPaths, createDocument } from "./Utilities.js";
import {
type ApiItemTransformationConfiguration,
type ApiItemTransformationOptions,
Expand All @@ -37,10 +37,10 @@ export function transformApiModel(options: ApiItemTransformationOptions): Docume
// If a package has multiple entry-points, it's possible for the same API item to appear under more than one
// entry-point (i.e., we are traversing a graph, rather than a tree).
// To avoid redundant computation, we will keep a ledger of which API items we have transformed.
const documents: Map<ApiItem, DocumentNode> = new Map<ApiItem, DocumentNode>();
const documentsMap: Map<ApiItem, DocumentNode> = new Map<ApiItem, DocumentNode>();

// Always render Model document (this is the "root" of the generated documentation suite).
documents.set(apiModel, createDocumentForApiModel(apiModel, config));
documentsMap.set(apiModel, createDocumentForApiModel(apiModel, config));

const packages = apiModel.packages;

Expand Down Expand Up @@ -75,42 +75,49 @@ export function transformApiModel(options: ApiItemTransformationOptions): Docume

const entryPoint = packageEntryPoints[0];

documents.set(
documentsMap.set(
packageItem,
createDocumentForSingleEntryPointPackage(packageItem, entryPoint, config),
);

const packageDocumentItems = getDocumentItems(entryPoint, config);
for (const apiItem of packageDocumentItems) {
if (!documents.has(apiItem)) {
documents.set(apiItem, apiItemToDocument(apiItem, config));
if (!documentsMap.has(apiItem)) {
documentsMap.set(apiItem, apiItemToDocument(apiItem, config));
}
}
} else {
// If a package contains multiple entry-points, we will generate a separate document for each.
// The package-level document will enumerate the entry-points.

documents.set(
documentsMap.set(
packageItem,
createDocumentForMultiEntryPointPackage(packageItem, packageEntryPoints, config),
);

for (const entryPoint of packageEntryPoints) {
documents.set(entryPoint, createDocumentForApiEntryPoint(entryPoint, config));
documentsMap.set(entryPoint, createDocumentForApiEntryPoint(entryPoint, config));

const packageDocumentItems = getDocumentItems(entryPoint, config);
for (const apiItem of packageDocumentItems) {
if (!documents.has(apiItem)) {
documents.set(apiItem, apiItemToDocument(apiItem, config));
if (!documentsMap.has(apiItem)) {
documentsMap.set(apiItem, apiItemToDocument(apiItem, config));
}
}
}
}
}

logger.success("API Model documents generated!");
const documents = [...documentsMap.values()];

try {
checkForDuplicateDocumentPaths(documents);
} catch (error: unknown) {
logger.warning((error as Error).message);
}

return [...documents.values()];
logger.success("API Model documents generated!");
return documents;
}

/**
Expand Down
41 changes: 41 additions & 0 deletions tools/api-markdown-documenter/src/api-item-transforms/Utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,44 @@ function resolveSymbolicLink(

return getLinkForApiItem(resolvedReference, config);
}

/**
* Checks for duplicate {@link DocumentNode.documentPath}s among the provided set of documents.
* @throws If any duplicates are found.
*/
export function checkForDuplicateDocumentPaths(documents: readonly DocumentNode[]): void {
const documentPathMap = new Map<string, DocumentNode[]>();
for (const document of documents) {
let entries = documentPathMap.get(document.documentPath);
if (entries === undefined) {
entries = [];
documentPathMap.set(document.documentPath, entries);
}
entries.push(document);
}

const duplicates = [...documentPathMap.entries()].filter(
([, documentsUnderPath]) => documentsUnderPath.length > 1,
);

if (duplicates.length === 0) {
return;
}

const errorMessageLines = ["Duplicate output paths found among the generated documents:"];

for (const [documentPath, documentsUnderPath] of duplicates) {
errorMessageLines.push(`- ${documentPath}`);
for (const document of documentsUnderPath) {
const errorEntry = document.apiItem
? `${document.apiItem.displayName} (${document.apiItem.kind})`
: "(No corresponding API item)";
errorMessageLines.push(` - ${errorEntry}`);
}
}
errorMessageLines.push(
"Check your configuration to ensure different API items do not result in the same output path.",
);

throw new Error(errorMessageLines.join("\n"));
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ export {
export { transformTsdocNode } from "./TsdocNodeTransforms.js";
export { apiItemToDocument, apiItemToSections } from "./TransformApiItem.js";
export { transformApiModel } from "./TransformApiModel.js";
export { checkForDuplicateDocumentPaths } from "./Utilities.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import { expect } from "chai";

import type { DocumentNode } from "../../index.js";
import { checkForDuplicateDocumentPaths } from "../Utilities.js";

describe("ApiItem to Documentation transformation utilities tests", () => {
describe("checkForDuplicateDocumentPaths", () => {
it("Empty list", () => {
expect(() => checkForDuplicateDocumentPaths([])).to.not.throw();
});

it("No duplicates", () => {
const documents: DocumentNode[] = [
{ documentPath: "foo" } as unknown as DocumentNode,
{ documentPath: "bar" } as unknown as DocumentNode,
{ documentPath: "baz" } as unknown as DocumentNode,
];
expect(() => checkForDuplicateDocumentPaths(documents)).to.not.throw();
});

it("Contains duplicates", () => {
const documents: DocumentNode[] = [
{ documentPath: "foo" } as unknown as DocumentNode,
{ documentPath: "bar" } as unknown as DocumentNode,
{ documentPath: "foo" } as unknown as DocumentNode,
];
expect(() => checkForDuplicateDocumentPaths(documents)).to.throw();
});
});
});
15 changes: 4 additions & 11 deletions tools/api-markdown-documenter/src/test/EndToEndTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import type { Suite } from "mocha";

import { loadModel } from "../LoadModel.js";
import {
transformApiModel,
type ApiItemTransformationOptions,
checkForDuplicateDocumentPaths,
transformApiModel,
} from "../api-item-transforms/index.js";
import type { DocumentNode } from "../documentation-domain/index.js";

Expand Down Expand Up @@ -155,16 +156,8 @@ export function endToEndTests<TRenderConfig>(
it("Ensure no duplicate file paths", () => {
const documents = transformApiModel(apiItemTransformConfig);

const pathMap = new Map<string, DocumentNode>();
for (const document of documents) {
if (pathMap.has(document.documentPath)) {
expect.fail(
`Rendering generated multiple documents to be rendered to the same file path.`,
);
} else {
pathMap.set(document.documentPath, document);
}
}
// Will throw if any duplicates are found.
checkForDuplicateDocumentPaths(documents);
});

// Perform actual output snapshot comparison test against checked-in test collateral.
Expand Down

0 comments on commit 172933c

Please sign in to comment.