Skip to content

Commit

Permalink
Update the algorithm that looks for 'tsdoc-metadata.json' to more cor…
Browse files Browse the repository at this point in the history
…rectly reflect the NodeJS resolution algorithm.
  • Loading branch information
iclanton committed May 26, 2024
1 parent 7eba571 commit 93bca77
Show file tree
Hide file tree
Showing 6 changed files with 465 additions and 148 deletions.
183 changes: 144 additions & 39 deletions apps/api-extractor/src/analyzer/PackageMetadataManager.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// 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 path from 'path';
import semver from 'semver';

import {
type PackageJsonLookup,
FileSystem,
JsonFile,
type NewlineKind,
type INodePackageJson,
type JsonObject
type JsonObject,
type IPackageJsonExports
} from '@rushstack/node-core-library';
import { Extractor } from '../api/Extractor';
import type { MessageRouter } from '../collector/MessageRouter';
Expand Down Expand Up @@ -42,6 +44,125 @@ export class PackageMetadata {
}
}

const TSDOC_METADATA_FILENAME: 'tsdoc-metadata.json' = 'tsdoc-metadata.json';

const TSDOC_METADATA_RESOLUTION_FUNCTIONS: [
...((packageJson: INodePackageJson) => string | undefined)[],
(packageJson: INodePackageJson) => string
] = [
/**
* 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.
*/
({ tsdocMetadata }) => 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.
*/
({ exports }) => {
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(exports[0])}/${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;
}
}
},
/**
* 3. If package.json contains a `typesVersions` field, look for the version
* matching the highest minimum version that either includes a "." or "*" entry.
*/
({ typesVersions }) => {
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}`;
}
}
},
/**
* 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`.
*/
({ typings, types }) => {
const typesField: string | undefined = types ?? typings;
if (typesField) {
return `${path.dirname(typesField)}/${TSDOC_METADATA_FILENAME}`;
}
},
({ main }) => {
if (main) {
return `${path.dirname(main)}/${TSDOC_METADATA_FILENAME}`;
}
},
/**
* As a final fallback, place the file in the root of the package.
*/
() => TSDOC_METADATA_FILENAME
];

/**
* This class maintains a cache of analyzed information obtained from package.json
* files. It is built on top of the PackageJsonLookup class.
Expand All @@ -56,7 +177,7 @@ export class PackageMetadata {
* Use ts.program.isSourceFileFromExternalLibrary() to test source files before passing the to PackageMetadataManager.
*/
export class PackageMetadataManager {
public static tsdocMetadataFilename: string = 'tsdoc-metadata.json';
public static tsdocMetadataFilename: string = TSDOC_METADATA_FILENAME;

private readonly _packageJsonLookup: PackageJsonLookup;
private readonly _messageRouter: MessageRouter;
Expand All @@ -70,48 +191,31 @@ export class PackageMetadataManager {
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(
/**
* 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.
*
* @internal
*/
public static _resolveTsdocMetadataPathFromPackageJson(
packageFolder: string,
packageJson: INodePackageJson
): string {
const tsdocMetadataFilename: string = PackageMetadataManager.tsdocMetadataFilename;

let tsdocMetadataRelativePath: string;

if (packageJson.tsdocMetadata) {
// 1. If package.json contains a field such as "tsdocMetadata": "./path1/path2/tsdoc-metadata.json",
// then that takes precedence. This convention will be rarely needed, since the other rules below generally
// produce a good result.
tsdocMetadataRelativePath = packageJson.tsdocMetadata;
} else if (packageJson.typings) {
// 2. If package.json contains a field such as "typings": "./path1/path2/index.d.ts", then we look
// for the file under "./path1/path2/tsdoc-metadata.json"
tsdocMetadataRelativePath = path.join(path.dirname(packageJson.typings), tsdocMetadataFilename);
} else if (packageJson.main) {
// 3. If package.json contains a field such as "main": "./path1/path2/index.js", then we look for
// the file under "./path1/path2/tsdoc-metadata.json"
tsdocMetadataRelativePath = path.join(path.dirname(packageJson.main), tsdocMetadataFilename);
} else if (
typeof packageJson.exports === 'object' &&
!Array.isArray(packageJson.exports) &&
packageJson.exports?.['.']?.types
) {
// 4. If package.json contains a field such as "exports": { ".": { "types": "./path1/path2/index.d.ts" } },
// then we look for the file under "./path1/path2/tsdoc-metadata.json"
tsdocMetadataRelativePath = path.join(
path.dirname(packageJson.exports['.'].types),
tsdocMetadataFilename
);
} else {
// 5. If none of the above rules apply, then by default we look for the file under "./tsdoc-metadata.json"
// since the default entry point is "./index.js"
tsdocMetadataRelativePath = tsdocMetadataFilename;
let tsdocMetadataRelativePath: string | undefined;
for (const tsdocMetadataResolutionFunction of TSDOC_METADATA_RESOLUTION_FUNCTIONS) {
tsdocMetadataRelativePath = tsdocMetadataResolutionFunction(packageJson);
if (tsdocMetadataRelativePath) {
break;
}
}

// Always resolve relative to the package folder.
const tsdocMetadataPath: string = path.resolve(packageFolder, tsdocMetadataRelativePath);
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;
}

Expand All @@ -128,6 +232,7 @@ export class PackageMetadataManager {
if (tsdocMetadataPath) {
return path.resolve(packageFolder, tsdocMetadataPath);
}

return PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(packageFolder, packageJson);
}

Expand Down
Loading

0 comments on commit 93bca77

Please sign in to comment.