Skip to content

[api-extractor] Customize which TSDoc tags appear in API reports #5079

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions apps/api-extractor/src/api/ExtractorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '@rushstack/node-core-library';
import { type IRigConfig, RigConfig } from '@rushstack/rig-package';
import { EnumMemberOrder, ReleaseTag } from '@microsoft/api-extractor-model';
import { TSDocConfiguration } from '@microsoft/tsdoc';
import { TSDocConfiguration, TSDocTagDefinition } from '@microsoft/tsdoc';
import { TSDocConfigFile } from '@microsoft/tsdoc-config';

import type {
Expand Down Expand Up @@ -173,6 +173,25 @@ export interface IExtractorConfigApiReport {
fileName: string;
}

/** Default {@link IConfigApiReport.reportVariants} */
const defaultApiReportVariants: readonly ApiReportVariant[] = ['complete'];

/**
* Default {@link IConfigApiReport.tagsToReport}.
*
* @remarks
* Note that this list is externally documented, and directly affects report output.
* Also note that the order of tags in this list is significant, as it determines the order of tags in the report.
* Any changes to this list should be considered breaking.
*/
const defaultTagsToReport: Readonly<Record<`@${string}`, boolean>> = {
'@sealed': true,
'@virtual': true,
'@override': true,
'@eventProperty': true,
'@deprecated': true
};

interface IExtractorConfigParameters {
projectFolder: string;
packageJson: INodePackageJson | undefined;
Expand All @@ -187,6 +206,7 @@ interface IExtractorConfigParameters {
reportFolder: string;
reportTempFolder: string;
apiReportIncludeForgottenExports: boolean;
tagsToReport: Readonly<Record<`@${string}`, boolean>>;
docModelGenerationOptions: IApiModelGenerationOptions | undefined;
apiJsonFilePath: string;
docModelIncludeForgottenExports: boolean;
Expand Down Expand Up @@ -282,6 +302,8 @@ export class ExtractorConfig {
public readonly reportFolder: string;
/** {@inheritDoc IConfigApiReport.reportTempFolder} */
public readonly reportTempFolder: string;
/** {@inheritDoc IConfigApiReport.tagsToReport} */
public readonly tagsToReport: Readonly<Record<`@${string}`, boolean>>;

/**
* Gets the file path for the "complete" (default) report configuration, if one was specified.
Expand Down Expand Up @@ -375,6 +397,7 @@ export class ExtractorConfig {
reportConfigs,
reportFolder,
reportTempFolder,
tagsToReport,
docModelGenerationOptions,
apiJsonFilePath,
docModelIncludeForgottenExports,
Expand Down Expand Up @@ -407,6 +430,7 @@ export class ExtractorConfig {
this.reportConfigs = reportConfigs;
this.reportFolder = reportFolder;
this.reportTempFolder = reportTempFolder;
this.tagsToReport = tagsToReport;
this.docModelGenerationOptions = docModelGenerationOptions;
this.apiJsonFilePath = apiJsonFilePath;
this.docModelIncludeForgottenExports = docModelIncludeForgottenExports;
Expand Down Expand Up @@ -960,12 +984,17 @@ export class ExtractorConfig {
}
}

if (configObject.apiReport?.tagsToReport) {
_validateTagsToReport(configObject.apiReport.tagsToReport);
}

const apiReportEnabled: boolean = configObject.apiReport?.enabled ?? false;
const apiReportIncludeForgottenExports: boolean =
configObject.apiReport?.includeForgottenExports ?? false;
let reportFolder: string = tokenContext.projectFolder;
let reportTempFolder: string = tokenContext.projectFolder;
const reportConfigs: IExtractorConfigApiReport[] = [];
let tagsToReport: Record<`@${string}`, boolean> = {};
if (apiReportEnabled) {
// Undefined case checked above where we assign `apiReportEnabled`
const apiReportConfig: IConfigApiReport = configObject.apiReport!;
Expand Down Expand Up @@ -998,7 +1027,8 @@ export class ExtractorConfig {
reportFileNameBase = '<unscopedPackageName>';
}

const reportVariantKinds: ApiReportVariant[] = apiReportConfig.reportVariants ?? ['complete'];
const reportVariantKinds: readonly ApiReportVariant[] =
apiReportConfig.reportVariants ?? defaultApiReportVariants;

for (const reportVariantKind of reportVariantKinds) {
// Omit the variant kind from the "complete" report file name for simplicity and for backwards compatibility.
Expand Down Expand Up @@ -1032,6 +1062,11 @@ export class ExtractorConfig {
tokenContext
);
}

tagsToReport = {
...defaultTagsToReport,
...apiReportConfig.tagsToReport
};
}

let docModelGenerationOptions: IApiModelGenerationOptions | undefined = undefined;
Expand Down Expand Up @@ -1188,6 +1223,7 @@ export class ExtractorConfig {
reportFolder,
reportTempFolder,
apiReportIncludeForgottenExports,
tagsToReport,
docModelGenerationOptions,
apiJsonFilePath,
docModelIncludeForgottenExports,
Expand Down Expand Up @@ -1319,3 +1355,47 @@ export class ExtractorConfig {
throw new Error(`The "${fieldName}" value contains extra token characters ("<" or ">"): ${value}`);
}
}

const releaseTags: Set<string> = new Set(['@public', '@alpha', '@beta', '@internal']);

/**
* Validate {@link ExtractorConfig.tagsToReport}.
*/
function _validateTagsToReport(
tagsToReport: Record<string, boolean>
): asserts tagsToReport is Record<`@${string}`, boolean> {
const includedReleaseTags: string[] = [];
const invalidTags: [string, string][] = []; // tag name, error
for (const tag of Object.keys(tagsToReport)) {
if (releaseTags.has(tag)) {
// If a release tags is specified, regardless of whether it is enabled, we will throw an error.
// Release tags must not be specified.
includedReleaseTags.push(tag);
}

// If the tag is invalid, generate an error string from the inner error message.
try {
TSDocTagDefinition.validateTSDocTagName(tag);
} catch (error) {
invalidTags.push([tag, (error as Error).message]);
}
}

const errorMessages: string[] = [];
for (const includedReleaseTag of includedReleaseTags) {
errorMessages.push(
`${includedReleaseTag}: Release tags are always included in API reports and must not be specified`
);
}
for (const [invalidTag, innerError] of invalidTags) {
errorMessages.push(`${invalidTag}: ${innerError}`);
}

if (errorMessages.length > 0) {
const errorMessage: string = [
`"tagsToReport" contained one or more invalid tags:`,
...errorMessages
].join('\n\t- ');
throw new Error(errorMessage);
}
}
35 changes: 35 additions & 0 deletions apps/api-extractor/src/api/IConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,41 @@ export interface IConfigApiReport {
* @defaultValue `false`
*/
includeForgottenExports?: boolean;

/**
* Specifies a list of {@link https://tsdoc.org/ | TSDoc} tags that should be reported in the API report file for
* items whose documentation contains them.
*
* @remarks
* Tag names must begin with `@`.
*
* This list may include standard TSDoc tags as well as custom ones.
* For more information on defining custom TSDoc tags, see
* {@link https://api-extractor.com/pages/configs/tsdoc_json/#defining-your-own-tsdoc-tags | here}.
*
* Note that an item's release tag will always reported; this behavior cannot be overridden.
*
* @defaultValue `@sealed`, `@virtual`, `@override`, `@eventProperty`, and `@deprecated`
*
* @example Omitting default tags
* To omit the `@sealed` and `@virtual` tags from API reports, you would specify `tagsToReport` as follows:
* ```json
* "tagsToReport": {
* "@sealed": false,
* "@virtual": false
* }
* ```
*
* @example Including additional tags
* To include additional tags to the set included in API reports, you could specify `tagsToReport` like this:
* ```json
* "tagsToReport": {
* "@customTag": true
* }
* ```
* This will result in `@customTag` being included in addition to the default tags.
*/
tagsToReport?: Readonly<Record<`@${string}`, boolean>>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const testDataFolder: string = path.join(__dirname, 'test-data');

describe('Extractor-custom-tags', () => {
describe('should use a TSDocConfiguration', () => {
it.only("with custom TSDoc tags defined in the package's tsdoc.json", () => {
it("with custom TSDoc tags defined in the package's tsdoc.json", () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json')
);
Expand All @@ -20,7 +20,7 @@ describe('Extractor-custom-tags', () => {
expect(tsdocConfiguration.tryGetTagDefinition('@inline')).not.toBe(undefined);
expect(tsdocConfiguration.tryGetTagDefinition('@modifier')).not.toBe(undefined);
});
it.only("with custom TSDoc tags enabled per the package's tsdoc.json", () => {
it("with custom TSDoc tags enabled per the package's tsdoc.json", () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json')
);
Expand All @@ -33,7 +33,7 @@ describe('Extractor-custom-tags', () => {
expect(tsdocConfiguration.isTagSupported(inline)).toBe(true);
expect(tsdocConfiguration.isTagSupported(modifier)).toBe(false);
});
it.only("with standard tags and API Extractor custom tags defined and supported when the package's tsdoc.json extends API Extractor's tsdoc.json", () => {
it("with standard tags and API Extractor custom tags defined and supported when the package's tsdoc.json extends API Extractor's tsdoc.json", () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json')
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ function expectEqualPaths(path1: string, path2: string): void {

// Tests for expanding the "<lookup>" token for the "projectFolder" setting in api-extractor.json
describe(`${ExtractorConfig.name}.${ExtractorConfig.loadFileAndPrepare.name}`, () => {
it.only('config-lookup1: looks up ./api-extractor.json', () => {
it('config-lookup1: looks up ./api-extractor.json', () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'config-lookup1/api-extractor.json')
);
expectEqualPaths(extractorConfig.projectFolder, path.join(testDataFolder, 'config-lookup1'));
});
it.only('config-lookup2: looks up ./config/api-extractor.json', () => {
it('config-lookup2: looks up ./config/api-extractor.json', () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'config-lookup2/config/api-extractor.json')
);
expectEqualPaths(extractorConfig.projectFolder, path.join(testDataFolder, 'config-lookup2'));
});
it.only('config-lookup3a: looks up ./src/test/config/api-extractor.json', () => {
it('config-lookup3a: looks up ./src/test/config/api-extractor.json', () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'config-lookup3/src/test/config/api-extractor.json')
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'path';

import { ExtractorConfig } from '../ExtractorConfig';

const testDataFolder: string = path.join(__dirname, 'test-data');

describe('ExtractorConfig-tagsToReport', () => {
it('tagsToReport merge correctly', () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'tags-to-report/api-extractor.json')
);
const { tagsToReport } = extractorConfig;
expect(tagsToReport).toEqual({
'@deprecated': true,
'@eventProperty': true,
'@myCustomTag': true,
'@myCustomTag2': false,
'@override': false,
'@sealed': true,
'@virtual': true
});
});
it('Invalid tagsToReport values', () => {
const expectedErrorMessage = `"tagsToReport" contained one or more invalid tags:
\t- @public: Release tags are always included in API reports and must not be specified
\t- @-invalid-tag-2: A TSDoc tag name must start with a letter and contain only letters and numbers`;
expect(() =>
ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'invalid-tags-to-report/api-extractor.json')
)
).toThrowError(expectedErrorMessage);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test case to ensure that merging of `apiReport.tagsToReport` is correct.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "../../../../schemas/api-extractor.schema.json",

"mainEntryPointFilePath": "index.d.ts",

"apiReport": {
"enabled": true,
"tagsToReport": {
"@validTag1": true, // Valid custom tag
"@-invalid-tag-2": true, // Invalid tag - invalid characters
"@public": false, // Release tags must not be specified
"@override": false // Valid (override base tag)
}
},

"docModel": {
"enabled": true
},

"dtsRollup": {
"enabled": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty file
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "tags-to-report",
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test case to ensure that merging of `apiReport.tagsToReport` is correct.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",

"mainEntryPointFilePath": "index.d.ts",

"apiReport": {
"enabled": true
},

"docModel": {
"enabled": true
},

"dtsRollup": {
"enabled": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./api-extractor-base.json",

"apiReport": {
"tagsToReport": {
"@myCustomTag": true, // Enable reporting of custom tag
"@override": false, // Disable default reporting of `@override` tag
"@myCustomTag2": false // Disable reporting of custom tag (not included by base config)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty file
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "tags-to-report",
"version": "1.0.0"
}
Loading