Files
discord.js/packages/api-extractor/src/analyzer/PackageMetadataManager.ts
Qjuh b3db92edfb feat(api-extractor): support multiple entrypoints (#10829)
* feat(api-extractor): support multiple entrypoints

* chore: initial support in generateSplitDocumentation

* chore: bring in line with upstream

* refactor: multiple entrypoints in scripts

* fix: split docs

* feat: website

* fix: docs failing on next

* fix: don't include dtypes for now

* refactor: don't fetch entrypoint if there is none

---------

Co-authored-by: iCrawl <buechler.noel@outlook.com>
2025-05-12 23:48:41 +02:00

327 lines
11 KiB
TypeScript

// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
/* eslint-disable sonarjs/no-nested-switch */
import path from 'node:path';
import {
type PackageJsonLookup,
FileSystem,
JsonFile,
type NewlineKind,
type INodePackageJson,
type JsonObject,
type IPackageJsonExports,
} from '@rushstack/node-core-library';
import semver from 'semver';
import { ConsoleMessageId } from '../api/ConsoleMessageId.js';
import { Extractor } from '../api/Extractor.js';
import type { MessageRouter } from '../collector/MessageRouter.js';
/**
* Represents analyzed information for a package.json file.
* This object is constructed and returned by PackageMetadataManager.
*/
export class PackageMetadata {
/**
* The absolute path to the package.json file being analyzed.
*/
public readonly packageJsonPath: string;
/**
* The parsed contents of package.json. Note that PackageJsonLookup
* only includes essential fields.
*/
public readonly packageJson: INodePackageJson;
/**
* If true, then the package's documentation comments can be assumed
* to contain API Extractor compatible TSDoc tags.
*/
public readonly aedocSupported: boolean;
public constructor(packageJsonPath: string, packageJson: INodePackageJson, aedocSupported: boolean) {
this.packageJsonPath = packageJsonPath;
this.packageJson = packageJson;
this.aedocSupported = aedocSupported;
}
}
const TSDOC_METADATA_FILENAME = 'tsdoc-metadata.json' as const;
/**
* 1. If package.json a `"tsdocMetadata": "./path1/path2/tsdoc-metadata.json"` field
* then that takes precedence. This convention will be rarely needed, since the other rules below generally
* produce a good result.
*/
function _tryResolveTsdocMetadataFromTsdocMetadataField({ tsdocMetadata }: INodePackageJson): string | undefined {
return tsdocMetadata;
}
/**
* 2. If package.json contains a `"exports": { ".": { "types": "./path1/path2/index.d.ts" } }` field,
* then we look for the file under "./path1/path2/tsdoc-metadata.json"
*
* This always looks for a "." and then a "*" entry in the exports field, and then evaluates for
* a "types" field in that entry.
*/
function _tryResolveTsdocMetadataFromExportsField({ exports }: INodePackageJson): string | undefined {
switch (typeof exports) {
case 'string': {
return `${path.dirname(exports)}/${TSDOC_METADATA_FILENAME}`;
}
case 'object': {
if (Array.isArray(exports)) {
const [firstExport] = exports;
// Take the first entry in the array
if (firstExport) {
return `${path.dirname(firstExport)}/${TSDOC_METADATA_FILENAME}`;
}
} else {
const rootExport: IPackageJsonExports | string | null | undefined = exports['.'] ?? exports['*'];
switch (typeof rootExport) {
case 'string': {
return `${path.dirname(rootExport)}/${TSDOC_METADATA_FILENAME}`;
}
case 'object': {
let typesExport: IPackageJsonExports | string | undefined = rootExport?.types;
while (typesExport) {
switch (typeof typesExport) {
case 'string': {
return `${path.dirname(typesExport)}/${TSDOC_METADATA_FILENAME}`;
}
case 'object': {
typesExport = typesExport?.types;
break;
}
}
}
}
}
}
break;
}
}
return undefined;
}
/**
* 3. If package.json contains a `typesVersions` field, look for the version
* matching the highest minimum version that either includes a "." or "*" entry.
*/
function _tryResolveTsdocMetadataFromTypesVersionsField({ typesVersions }: INodePackageJson): string | undefined {
if (typesVersions) {
let highestMinimumMatchingSemver: semver.SemVer | undefined;
let latestMatchingPath: string | undefined;
for (const [version, paths] of Object.entries(typesVersions)) {
let range: semver.Range;
try {
range = new semver.Range(version);
} catch {
continue;
}
const minimumMatchingSemver: semver.SemVer | null = semver.minVersion(range);
if (
minimumMatchingSemver &&
(!highestMinimumMatchingSemver || semver.gt(minimumMatchingSemver, highestMinimumMatchingSemver))
) {
const pathEntry: string[] | undefined = paths['.'] ?? paths['*'];
const firstPath: string | undefined = pathEntry?.[0];
if (firstPath) {
highestMinimumMatchingSemver = minimumMatchingSemver;
latestMatchingPath = firstPath;
}
}
}
if (latestMatchingPath) {
return `${path.dirname(latestMatchingPath)}/${TSDOC_METADATA_FILENAME}`;
}
}
return undefined;
}
/**
* 4. If package.json contains a `"types": "./path1/path2/index.d.ts"` or a `"typings": "./path1/path2/index.d.ts"`
* field, then we look for the file under "./path1/path2/tsdoc-metadata.json".
*
* @remarks
* `types` takes precedence over `typings`.
*/
function _tryResolveTsdocMetadataFromTypesOrTypingsFields({ typings, types }: INodePackageJson): string | undefined {
const typesField: string | undefined = types ?? typings;
if (typesField) {
return `${path.dirname(typesField)}/${TSDOC_METADATA_FILENAME}`;
}
return undefined;
}
/**
* 5. If package.json contains a `"main": "./path1/path2/index.js"` field, then we look for the file under
* "./path1/path2/tsdoc-metadata.json".
*/
function _tryResolveTsdocMetadataFromMainField({ main }: INodePackageJson): string | undefined {
if (main) {
return `${path.dirname(main)}/${TSDOC_METADATA_FILENAME}`;
}
return undefined;
}
/**
* This class maintains a cache of analyzed information obtained from package.json
* files. It is built on top of the PackageJsonLookup class.
*
* @remarks
*
* IMPORTANT: Don't use PackageMetadataManager to analyze source files from the current project:
* 1. Files such as tsdoc-metadata.json may not have been built yet, and thus may contain incorrect information.
* 2. The current project is not guaranteed to have a package.json file at all. For example, API Extractor can
* be invoked on a bare .d.ts file.
*
* Use ts.program.isSourceFileFromExternalLibrary() to test source files before passing the to PackageMetadataManager.
*/
export class PackageMetadataManager {
public static tsdocMetadataFilename: string = TSDOC_METADATA_FILENAME;
private readonly _packageJsonLookup: PackageJsonLookup;
private readonly _messageRouter: MessageRouter;
private readonly _packageMetadataByPackageJsonPath: Map<string, PackageMetadata> = new Map<string, PackageMetadata>();
public constructor(packageJsonLookup: PackageJsonLookup, messageRouter: MessageRouter) {
this._packageJsonLookup = packageJsonLookup;
this._messageRouter = messageRouter;
}
/**
* This feature is still being standardized: https://github.com/microsoft/tsdoc/issues/7
* In the future we will use the \@microsoft/tsdoc library to read this file.
*/
private static _resolveTsdocMetadataPathFromPackageJson(
packageFolder: string,
packageJson: INodePackageJson,
): string {
const tsdocMetadataRelativePath: string =
_tryResolveTsdocMetadataFromTsdocMetadataField(packageJson) ??
_tryResolveTsdocMetadataFromExportsField(packageJson) ??
_tryResolveTsdocMetadataFromTypesVersionsField(packageJson) ??
_tryResolveTsdocMetadataFromTypesOrTypingsFields(packageJson) ??
_tryResolveTsdocMetadataFromMainField(packageJson) ??
// As a final fallback, place the file in the root of the package.
TSDOC_METADATA_FILENAME;
// Always resolve relative to the package folder.
const tsdocMetadataPath: string = path.resolve(
packageFolder,
// This non-null assertion is safe because the last entry in TSDOC_METADATA_RESOLUTION_FUNCTIONS
// returns a non-undefined value.
tsdocMetadataRelativePath!,
);
return tsdocMetadataPath;
}
/**
* @param packageFolder - The package folder
* @param packageJson - The package JSON
* @param tsdocMetadataPath - An explicit path that can be configured in api-extractor.json.
* If this parameter is not an empty string, it overrides the normal path calculation.
* @returns the absolute path to the TSDoc metadata file
*/
public static resolveTsdocMetadataPath(
packageFolder: string,
packageJson: INodePackageJson,
tsdocMetadataPath?: string,
): string {
if (tsdocMetadataPath) {
return path.resolve(packageFolder, tsdocMetadataPath);
}
return PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(packageFolder, packageJson);
}
/**
* Writes the TSDoc metadata file to the specified output file.
*/
public static writeTsdocMetadataFile(tsdocMetadataPath: string, newlineKind: NewlineKind): void {
const fileObject: JsonObject = {
tsdocVersion: '0.12',
toolPackages: [
{
packageName: '@discordjs/api-extractor',
packageVersion: Extractor.version,
},
],
};
const fileContent: string =
'// This file is read by tools that parse documentation comments conforming to the TSDoc standard.\n' +
'// It should be published with your NPM package. It should not be tracked by Git.\n' +
JsonFile.stringify(fileObject);
FileSystem.writeFile(tsdocMetadataPath, fileContent, {
convertLineEndings: newlineKind,
ensureFolderExists: true,
});
}
/**
* Finds the package.json in a parent folder of the specified source file, and
* returns a PackageMetadata object. If no package.json was found, then undefined
* is returned. The results are cached.
*/
public tryFetchPackageMetadata(sourceFilePath: string): PackageMetadata | undefined {
const packageJsonFilePath: string | undefined =
this._packageJsonLookup.tryGetPackageJsonFilePathFor(sourceFilePath);
if (!packageJsonFilePath) {
return undefined;
}
let packageMetadata: PackageMetadata | undefined = this._packageMetadataByPackageJsonPath.get(packageJsonFilePath);
if (!packageMetadata) {
const packageJson: INodePackageJson = this._packageJsonLookup.loadNodePackageJson(packageJsonFilePath);
const packageJsonFolder: string = path.dirname(packageJsonFilePath);
let aedocSupported = false;
const tsdocMetadataPath: string = PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(
packageJsonFolder,
packageJson,
);
if (FileSystem.exists(tsdocMetadataPath)) {
this._messageRouter.logVerbose(ConsoleMessageId.FoundTSDocMetadata, 'Found metadata in ' + tsdocMetadataPath);
// If the file exists at all, assume it was written by API Extractor
aedocSupported = true;
}
packageMetadata = new PackageMetadata(packageJsonFilePath, packageJson, aedocSupported);
this._packageMetadataByPackageJsonPath.set(packageJsonFilePath, packageMetadata);
}
return packageMetadata;
}
/**
* Returns true if the source file is part of a package whose .d.ts files support AEDoc annotations.
*/
public isAedocSupportedFor(sourceFilePath: string): boolean {
const packageMetadata: PackageMetadata | undefined = this.tryFetchPackageMetadata(sourceFilePath);
if (!packageMetadata) {
return false;
}
return packageMetadata.aedocSupported;
}
}