feat(build-tools): Add generate:node10Entrypoints command (#22937)

This PR add a new command to generate node10 type declaration
entrypoints. Most of the node10 code is extracted from [generate
entrypoint](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/library/commands/generateEntrypoints.ts)
command.
This commit is contained in:
Sonali Deshpande 2024-11-01 12:33:12 -07:00 коммит произвёл GitHub
Родитель 0c1886ec38
Коммит 533de79180
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
5 изменённых файлов: 256 добавлений и 87 удалений

Просмотреть файл

@ -9,6 +9,7 @@ Generate commands are used to create/update code, docs, readmes, etc.
* [`flub generate changelog`](#flub-generate-changelog)
* [`flub generate changeset`](#flub-generate-changeset)
* [`flub generate entrypoints`](#flub-generate-entrypoints)
* [`flub generate node10Entrypoints`](#flub-generate-node10entrypoints)
* [`flub generate packlist`](#flub-generate-packlist)
* [`flub generate releaseNotes`](#flub-generate-releasenotes)
* [`flub generate typetests`](#flub-generate-typetests)
@ -258,6 +259,25 @@ DESCRIPTION
_See code: [src/commands/generate/entrypoints.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/generate/entrypoints.ts)_
## `flub generate node10Entrypoints`
Generates node10 type declaration entrypoints for Fluid Framework API levels (/alpha, /beta, /internal etc.) as found in package.json "exports"
```
USAGE
$ flub generate node10Entrypoints [-v | --quiet]
LOGGING FLAGS
-v, --verbose Enable verbose logging.
--quiet Disable all logging.
DESCRIPTION
Generates node10 type declaration entrypoints for Fluid Framework API levels (/alpha, /beta, /internal etc.) as found
in package.json "exports"
```
_See code: [src/commands/generate/node10Entrypoints.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/generate/node10Entrypoints.ts)_
## `flub generate packlist`
Outputs a list of files that will be included in a package based on its 'files' property in package.json and any .npmignore files.

Просмотреть файл

@ -0,0 +1,128 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/
import fs from "node:fs/promises";
import type { PackageJson } from "@fluidframework/build-tools";
import { ApiLevel, BaseCommand, knownApiLevels } from "../../library/index.js";
// AB#8118 tracks removing the barrel files and importing directly from the submodules, including disabling this rule.
// eslint-disable-next-line import/no-internal-modules
import { readPackageJson, readTsConfig } from "../../library/package.js";
import {
type Node10CompatExportData,
getTypesPathFromPackage,
// AB#8118 tracks removing the barrel files and importing directly from the submodules, including disabling this rule.
// eslint-disable-next-line import/no-internal-modules
} from "../../library/packageExports.js";
import type { CommandLogger } from "../../logging.js";
export default class GenerateNode10EntrypointsCommand extends BaseCommand<
typeof GenerateNode10EntrypointsCommand
> {
static readonly description =
`Generates node10 type declaration entrypoints for Fluid Framework API levels (/alpha, /beta, /internal etc.) as found in package.json "exports"`;
public async run(): Promise<void> {
const packageJson = await readPackageJson();
const tsconfig = await readTsConfig();
let emitDeclarationOnly = false;
if (tsconfig.compilerOptions?.emitDeclarationOnly !== undefined) {
emitDeclarationOnly = true;
}
const mapNode10CompatExportPathToData = mapExportPathsFromPackage(
packageJson,
emitDeclarationOnly,
this.logger,
);
if (mapNode10CompatExportPathToData.size === 0) {
throw new Error(
'There are no API level "exports" requiring Node10 type compatibility generation.',
);
}
await generateNode10TypeEntrypoints(mapNode10CompatExportPathToData, this.logger);
}
}
export function mapExportPathsFromPackage(
packageJson: PackageJson,
emitDeclarationOnly: boolean,
log: CommandLogger,
): Map<string, Node10CompatExportData> {
const mapKeyToOutput = new Map<string, Node10CompatExportData>();
// Iterate through exports looking for properties with values matching keys in map.
for (const levels of knownApiLevels) {
// Exclude root "." path as "types" should handle that.
if (levels === ApiLevel.public) {
continue;
}
const typesPath = getTypesPathFromPackage(packageJson, levels, log);
if (typesPath === undefined) {
continue;
}
const node10ExportPath = typesPath
.replace(/\/index(\.d\.[cm]?ts)?$/, "/internal$1")
.replace(/^.*\//, "");
mapKeyToOutput.set(node10ExportPath, {
relPath: typesPath,
isTypeOnly: emitDeclarationOnly,
});
}
return mapKeyToOutput;
}
const generatedHeader: string = `/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/
/*
* THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
* Generated by "flub generate node10Entrypoints" in @fluid-tools/build-cli.
*/
`;
async function generateNode10TypeEntrypoints(
mapExportPathToData: Map<string, Node10CompatExportData>,
log: CommandLogger,
): Promise<void> {
/**
* List of out file save promises. Used to collect generated file save
* promises so we can await them all at once.
*/
const fileSavePromises: Promise<void>[] = [];
for (const [outFile, { relPath, isTypeOnly }] of mapExportPathToData.entries()) {
log.info(`\tGenerating ${outFile}`);
const jsImport = relPath.replace(/\.d\.([cm]?)ts/, ".$1js");
fileSavePromises.push(
fs.writeFile(
outFile,
isTypeOnly
? `${generatedHeader}export type * from "${relPath}";\n`
: `${generatedHeader}export * from "${jsImport}";\n`,
"utf8",
),
);
}
if (fileSavePromises.length === 0) {
log.info(`\tNo Node10 compat files generated.`);
}
await Promise.all(fileSavePromises);
}

Просмотреть файл

@ -16,7 +16,6 @@ import { Flags } from "@oclif/core";
import { PackageName } from "@rushstack/node-core-library";
import * as changeCase from "change-case";
import { readJson } from "fs-extra/esm";
import * as resolve from "resolve.exports";
import {
JSDoc,
ModuleKind,
@ -37,6 +36,9 @@ import {
} from "../../library/index.js";
// AB#8118 tracks removing the barrel files and importing directly from the submodules, including disabling this rule.
// eslint-disable-next-line import/no-internal-modules
import { getTypesPathFromPackage } from "../../library/packageExports.js";
// AB#8118 tracks removing the barrel files and importing directly from the submodules, including disabling this rule.
// eslint-disable-next-line import/no-internal-modules
import { type TestCaseTypeData, buildTestCase } from "../../typeValidator/testGeneration.js";
// AB#8118 tracks removing the barrel files and importing directly from the submodules, including disabling this rule.
// eslint-disable-next-line import/no-internal-modules
@ -221,91 +223,6 @@ function getTypesPathWithFallback(
return { typesPath: typesPath, entrypointUsed: chosenEntrypoint };
}
/**
* Finds the path to the types of a package using the package's export map or types/typings field.
* If the path is found, it is returned. Otherwise it returns undefined.
*
* This implementation uses resolve.exports to resolve the path to types for a level.
*
* @param packageJson - The package.json object to check for types paths.
* @param level - An API level to get types paths for.
* @returns A package relative path to the types.
*
* @remarks
*
* This implementation loosely follows TypeScript's process for finding types as described at
* {@link https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-main-and-types}. If an export
* map is found, the `types` and `typings` field are ignored. If an export map is not found, then the `types`/`typings`
* fields will be used as a fallback _only_ for the public API level (which corresponds to the default export).
*
* Importantly, this code _does not_ implement falling back to the `main` field when `types` and `typings` are missing,
* nor does it look up types from DefinitelyTyped (i.e. \@types/* packages). This fallback logic is not needed for our
* packages because we always specify types explicitly in the types field, and types are always included in our packages
* (as opposed to a separate \@types package).
*/
export function getTypesPathFromPackage(
packageJson: PackageJson,
level: ApiLevel,
log: Logger,
): string | undefined {
if (packageJson.exports === undefined) {
log.verbose(`${packageJson.name}: No export map found.`);
// Use types/typings field only when the public API level is used and no exports field is found
if (level === ApiLevel.public) {
log.verbose(`${packageJson.name}: Using the types/typings field value.`);
return packageJson.types ?? packageJson.typings;
}
// No exports and a non-public API level, so return undefined.
return undefined;
}
// Package has an export map, so map the requested API level to an entrypoint and check the exports conditions.
const entrypoint = level === ApiLevel.public ? "." : `./${level}`;
// resolve.exports sets some conditions by default, so the ones we supply supplement the defaults. For clarity the
// applied conditions are noted in comments.
let typesPath: string | undefined;
try {
// First try to resolve with the "import" condition, assuming the package is either ESM-only or dual-format.
// conditions: ["default", "types", "import", "node"]
const exports = resolve.exports(packageJson, entrypoint, { conditions: ["types"] });
// resolve.exports returns a `Exports.Output | void` type, though the documentation isn't clear under what
// conditions `void` would be the return type vs. just throwing an exception. Since the types say exports could be
// undefined or an empty array (Exports.Output is an array type), check for those conditions.
typesPath = exports === undefined || exports.length === 0 ? undefined : exports[0];
} catch {
// Catch and ignore any exceptions here; we'll retry with the require condition.
log.verbose(
`${packageJson.name}: No types found for ${entrypoint} using "import" condition.`,
);
}
// Found the types using the import condition, so return early.
if (typesPath !== undefined) {
return typesPath;
}
try {
// If nothing is found when using the "import" condition, try the "require" condition. It may be possible to do this
// in a single call to resolve.exports, but the documentation is a little unclear. This seems a safe, if inelegant
// solution.
// conditions: ["default", "types", "require", "node"]
const exports = resolve.exports(packageJson, entrypoint, {
conditions: ["types"],
require: true,
});
typesPath = exports === undefined || exports.length === 0 ? undefined : exports[0];
} catch {
// Catch and ignore any exceptions here; we'll retry with the require condition.
log.verbose(
`${packageJson.name}: No types found for ${entrypoint} using "require" condition.`,
);
}
return typesPath;
}
/**
* Calculates the file path for type validation tests.
*

Просмотреть файл

@ -4,7 +4,7 @@
*/
import { strict as assert } from "node:assert";
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
InterdependencyRange,
@ -28,12 +28,14 @@ import { PackageName } from "@rushstack/node-core-library";
import { compareDesc, differenceInBusinessDays } from "date-fns";
import execa from "execa";
import { readJson, readJsonSync } from "fs-extra/esm";
import JSON5 from "json5";
import latestVersion from "latest-version";
import ncu from "npm-check-updates";
import type { Index } from "npm-check-updates/build/src/types/IndexType.js";
import type { VersionSpec } from "npm-check-updates/build/src/types/VersionSpec.js";
import * as semver from "semver";
import type { TsConfigJson } from "type-fest";
import {
AllPackagesSelectionCriteria,
PackageSelectionCriteria,
@ -936,3 +938,18 @@ export function getTarballName(pkg: PackageJson | string): string {
export function getFullTarballName(pkg: PackageJson): string {
return `${getTarballName(pkg)}-${pkg?.version ?? 0}.tgz`;
}
/**
* Reads and parses the `package.json` file in the current directory.
* Use this function if you prefer the CLI command not to be implemented as `PackageCommand`command.
*/
export async function readPackageJson(): Promise<PackageJson> {
const packageJson = await readFile("./package.json", { encoding: "utf8" });
return JSON.parse(packageJson) as PackageJson;
}
// Reads and parses the `tsconfig.json` file in the current directory.
export async function readTsConfig(): Promise<TsConfigJson> {
const tsConfigContent = await readFile("./tsconfig.json", { encoding: "utf8" });
return JSON5.parse(tsConfigContent);
}

Просмотреть файл

@ -6,6 +6,8 @@
import path from "node:path";
import type { Logger, PackageJson } from "@fluidframework/build-tools";
import * as resolve from "resolve.exports";
import { ApiLevel } from "./apiLevel.js";
/**
* Properties for an "exports" leaf entry block in package.json.
@ -244,3 +246,88 @@ export function queryTypesResolutionPathsFromPackageExports<TOutKey>(
return { mapKeyToOutput, mapNode10CompatExportPathToData, mapTypesPathToExportPaths };
}
/**
* Finds the path to the types of a package using the package's export map or types/typings field.
* If the path is found, it is returned. Otherwise it returns undefined.
*
* This implementation uses resolve.exports to resolve the path to types for a level.
*
* @param packageJson - The package.json object to check for types paths.
* @param level - An API level to get types paths for.
* @returns A package relative path to the types.
*
* @remarks
*
* This implementation loosely follows TypeScript's process for finding types as described at
* {@link https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-main-and-types}. If an export
* map is found, the `types` and `typings` field are ignored. If an export map is not found, then the `types`/`typings`
* fields will be used as a fallback _only_ for the public API level (which corresponds to the default export).
*
* Importantly, this code _does not_ implement falling back to the `main` field when `types` and `typings` are missing,
* nor does it look up types from DefinitelyTyped (i.e. \@types/* packages). This fallback logic is not needed for our
* packages because we always specify types explicitly in the types field, and types are always included in our packages
* (as opposed to a separate \@types package).
*/
export function getTypesPathFromPackage(
packageJson: PackageJson,
level: ApiLevel,
log: Logger,
): string | undefined {
if (packageJson.exports === undefined) {
log.verbose(`${packageJson.name}: No export map found.`);
// Use types/typings field only when the public API level is used and no exports field is found
if (level === ApiLevel.public) {
log.verbose(`${packageJson.name}: Using the types/typings field value.`);
return packageJson.types ?? packageJson.typings;
}
// No exports and a non-public API level, so return undefined.
return undefined;
}
// Package has an export map, so map the requested API level to an entrypoint and check the exports conditions.
const entrypoint = level === ApiLevel.public ? "." : `./${level}`;
// resolve.exports sets some conditions by default, so the ones we supply supplement the defaults. For clarity the
// applied conditions are noted in comments.
let typesPath: string | undefined;
try {
// First try to resolve with the "import" condition, assuming the package is either ESM-only or dual-format.
// conditions: ["default", "types", "import", "node"]
const exports = resolve.exports(packageJson, entrypoint, { conditions: ["types"] });
// resolve.exports returns a `Exports.Output | void` type, though the documentation isn't clear under what
// conditions `void` would be the return type vs. just throwing an exception. Since the types say exports could be
// undefined or an empty array (Exports.Output is an array type), check for those conditions.
typesPath = exports === undefined || exports.length === 0 ? undefined : exports[0];
} catch {
// Catch and ignore any exceptions here; we'll retry with the require condition.
log.verbose(
`${packageJson.name}: No types found for ${entrypoint} using "import" condition.`,
);
}
// Found the types using the import condition, so return early.
if (typesPath !== undefined) {
return typesPath;
}
try {
// If nothing is found when using the "import" condition, try the "require" condition. It may be possible to do this
// in a single call to resolve.exports, but the documentation is a little unclear. This seems a safe, if inelegant
// solution.
// conditions: ["default", "types", "require", "node"]
const exports = resolve.exports(packageJson, entrypoint, {
conditions: ["types"],
require: true,
});
typesPath = exports === undefined || exports.length === 0 ? undefined : exports[0];
} catch {
// Catch and ignore any exceptions here; we'll retry with the require condition.
log.verbose(
`${packageJson.name}: No types found for ${entrypoint} using "require" condition.`,
);
}
return typesPath;
}