feat(js): add generateExportsField and additionalEntryPoints options to update exports field when building with tsc/swc (#18319)

This commit is contained in:
Jack Hsu 2023-08-01 11:23:48 -04:00 committed by GitHub
parent 90e4e7e7de
commit d63d3573c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 557 additions and 174 deletions

View File

@ -14,18 +14,34 @@
"type": "string", "type": "string",
"description": "The name of the main entry-point file.", "description": "The name of the main entry-point file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "main@(.js|.ts|.tsx)" "x-completion-glob": "main@(.js|.ts|.tsx)",
"x-priority": "important"
},
"generateExportsField": {
"type": "boolean",
"alias": "exports",
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundles.",
"x-priority": "important",
"default": false
},
"additionalEntryPoints": {
"type": "array",
"description": "Additional entry-points to add to exports field in the package.json file.",
"items": { "type": "string" },
"x-priority": "important"
}, },
"outputPath": { "outputPath": {
"type": "string", "type": "string",
"description": "The output path of the generated files.", "description": "The output path of the generated files.",
"x-completion-type": "directory" "x-completion-type": "directory",
"x-priority": "important"
}, },
"tsConfig": { "tsConfig": {
"type": "string", "type": "string",
"description": "The path to the Typescript configuration file.", "description": "The path to the Typescript configuration file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json" "x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
}, },
"swcrc": { "swcrc": {
"type": "string", "type": "string",

View File

@ -14,7 +14,21 @@
"type": "string", "type": "string",
"description": "The name of the main entry-point file.", "description": "The name of the main entry-point file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "main@(.js|.ts|.jsx|.tsx)" "x-completion-glob": "main@(.js|.ts|.jsx|.tsx)",
"x-priority": "important"
},
"generateExportsField": {
"type": "boolean",
"alias": "exports",
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundles.",
"default": false,
"x-priority": "important"
},
"additionalEntryPoints": {
"type": "array",
"description": "Additional entry-points to add to exports field in the package.json file.",
"items": { "type": "string" },
"x-priority": "important"
}, },
"rootDir": { "rootDir": {
"type": "string", "type": "string",
@ -23,13 +37,15 @@
"outputPath": { "outputPath": {
"type": "string", "type": "string",
"description": "The output path of the generated files.", "description": "The output path of the generated files.",
"x-completion-type": "directory" "x-completion-type": "directory",
"x-priority": "important"
}, },
"tsConfig": { "tsConfig": {
"type": "string", "type": "string",
"description": "The path to the Typescript configuration file.", "description": "The path to the Typescript configuration file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json" "x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
}, },
"assets": { "assets": {
"type": "array", "type": "array",

View File

@ -1,5 +1,6 @@
import { import {
checkFilesExist, updateJson,
updateProjectConfig,
cleanupProject, cleanupProject,
newProject, newProject,
runCLI, runCLI,
@ -8,6 +9,8 @@ import {
createFile, createFile,
uniq, uniq,
getPackageManagerCommand, getPackageManagerCommand,
readJson,
updateFile,
} from '@nx/e2e/utils'; } from '@nx/e2e/utils';
import { join } from 'path'; import { join } from 'path';
import { ensureDirSync } from 'fs-extra'; import { ensureDirSync } from 'fs-extra';
@ -123,17 +126,74 @@ describe('bundling libs', () => {
it('should support tsc and swc for building libs', () => { it('should support tsc and swc for building libs', () => {
const tscLib = uniq('tsclib'); const tscLib = uniq('tsclib');
const swcLib = uniq('swclib'); const swcLib = uniq('swclib');
const tscEsmLib = uniq('tscesmlib');
const swcEsmLib = uniq('swcesmlib');
runCLI(`generate @nx/js:lib ${tscLib} --bundler=tsc --no-interactive`); runCLI(`generate @nx/js:lib ${tscLib} --bundler=tsc --no-interactive`);
runCLI(`generate @nx/js:lib ${swcLib} --bundler=swc --no-interactive`); runCLI(`generate @nx/js:lib ${swcLib} --bundler=swc --no-interactive`);
runCLI(`generate @nx/js:lib ${tscEsmLib} --bundler=tsc --no-interactive`);
runCLI(`generate @nx/js:lib ${swcEsmLib} --bundler=swc --no-interactive`);
runCLI(`build ${tscLib}`); // Change module format to ESM
runCLI(`build ${swcLib}`); updateJson(`libs/${tscEsmLib}/tsconfig.json`, (json) => {
json.compilerOptions.module = 'esnext';
return json;
});
updateJson(`libs/${swcEsmLib}/.swcrc`, (json) => {
json.module.type = 'es6';
return json;
});
// Node ESM requires file extensions in imports so must add them before building
updateFile(
`libs/${tscEsmLib}/src/index.ts`,
`export * from './lib/${tscEsmLib}.js';`
);
updateFile(
`libs/${swcEsmLib}/src/index.ts`,
`export * from './lib/${swcEsmLib}.js';`
);
// Add additional entry points for `exports` field
updateProjectConfig(tscLib, (json) => {
json.targets.build.options.additionalEntryPoints = [
`libs/${tscLib}/src/foo/*.ts`,
];
return json;
});
updateFile(`libs/${tscLib}/src/foo/bar.ts`, `export const bar = 'bar';`);
updateFile(`libs/${tscLib}/src/foo/faz.ts`, `export const faz = 'faz';`);
updateProjectConfig(swcLib, (json) => {
json.targets.build.options.additionalEntryPoints = [
`libs/${swcLib}/src/foo/*.ts`,
];
return json;
});
updateFile(`libs/${swcLib}/src/foo/bar.ts`, `export const bar = 'bar';`);
updateFile(`libs/${swcLib}/src/foo/faz.ts`, `export const faz = 'faz';`);
runCLI(`build ${tscLib} --generateExportsField`);
runCLI(`build ${swcLib} --generateExportsField`);
runCLI(`build ${tscEsmLib} --generateExportsField`);
runCLI(`build ${swcEsmLib} --generateExportsField`);
expect(readJson(`dist/libs/${tscLib}/package.json`).exports).toEqual({
'./package.json': './package.json',
'.': './src/index.js',
'./foo/bar': './src/foo/bar.js',
'./foo/faz': './src/foo/faz.js',
});
expect(readJson(`dist/libs/${swcLib}/package.json`).exports).toEqual({
'./package.json': './package.json',
'.': './src/index.js',
'./foo/bar': './src/foo/bar.js',
'./foo/faz': './src/foo/faz.js',
});
const pmc = getPackageManagerCommand(); const pmc = getPackageManagerCommand();
let output: string; let output: string;
// Make sure outputs in commonjs project // Make sure CJS output is correct
createFile( createFile(
'test-cjs/package.json', 'test-cjs/package.json',
JSON.stringify( JSON.stringify(
@ -155,8 +215,13 @@ describe('bundling libs', () => {
` `
const { ${tscLib} } = require('@proj/${tscLib}'); const { ${tscLib} } = require('@proj/${tscLib}');
const { ${swcLib} } = require('@proj/${swcLib}'); const { ${swcLib} } = require('@proj/${swcLib}');
// additional entry-points
const { bar } = require('@proj/${tscLib}/foo/bar');
const { faz } = require('@proj/${swcLib}/foo/faz');
console.log(${tscLib}()); console.log(${tscLib}());
console.log(${swcLib}()); console.log(${swcLib}());
console.log(bar);
console.log(faz);
` `
); );
runCommand(pmc.install, { runCommand(pmc.install, {
@ -167,5 +232,42 @@ describe('bundling libs', () => {
}); });
expect(output).toContain(tscLib); expect(output).toContain(tscLib);
expect(output).toContain(swcLib); expect(output).toContain(swcLib);
expect(output).toContain('bar');
expect(output).toContain('faz');
// Make sure ESM output is correct
createFile(
'test-esm/package.json',
JSON.stringify(
{
name: 'test-esm',
private: true,
type: 'module',
dependencies: {
[`@proj/${tscEsmLib}`]: `file:../dist/libs/${tscEsmLib}`,
[`@proj/${swcEsmLib}`]: `file:../dist/libs/${swcEsmLib}`,
},
},
null,
2
)
);
createFile(
'test-esm/index.js',
`
import { ${tscEsmLib} } from '@proj/${tscEsmLib}';
import { ${swcEsmLib} } from '@proj/${swcEsmLib}';
console.log(${tscEsmLib}());
console.log(${swcEsmLib}());
`
);
runCommand(pmc.install, {
cwd: join(tmpProjPath(), 'test-esm'),
});
output = runCommand('node index.js', {
cwd: join(tmpProjPath(), 'test-esm'),
});
expect(output).toContain(tscEsmLib);
expect(output).toContain(swcEsmLib);
}, 500_000); }, 500_000);
}); });

View File

@ -20,7 +20,7 @@ import { InspectType, NodeExecutorOptions } from './schema';
import { calculateProjectBuildableDependencies } from '../../utils/buildable-libs-utils'; import { calculateProjectBuildableDependencies } from '../../utils/buildable-libs-utils';
import { killTree } from './lib/kill-tree'; import { killTree } from './lib/kill-tree';
import { fileExists } from 'nx/src/utils/fileutils'; import { fileExists } from 'nx/src/utils/fileutils';
import { getMainFileDirRelativeToProjectRoot } from '../../utils/get-main-file-dir'; import { getRelativeDirectoryToProjectRoot } from '../../utils/get-main-file-dir';
interface ActiveTask { interface ActiveTask {
id: string; id: string;
@ -379,10 +379,7 @@ function getFileToRun(
buildTargetExecutor === '@nx/js:swc' buildTargetExecutor === '@nx/js:swc'
) { ) {
outputFileName = path.join( outputFileName = path.join(
getMainFileDirRelativeToProjectRoot( getRelativeDirectoryToProjectRoot(buildOptions.main, project.data.root),
buildOptions.main,
project.data.root
),
fileName fileName
); );
} else { } else {

View File

@ -11,18 +11,36 @@
"type": "string", "type": "string",
"description": "The name of the main entry-point file.", "description": "The name of the main entry-point file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "main@(.js|.ts|.tsx)" "x-completion-glob": "main@(.js|.ts|.tsx)",
"x-priority": "important"
},
"generateExportsField": {
"type": "boolean",
"alias": "exports",
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundles.",
"x-priority": "important",
"default": false
},
"additionalEntryPoints": {
"type": "array",
"description": "Additional entry-points to add to exports field in the package.json file.",
"items": {
"type": "string"
},
"x-priority": "important"
}, },
"outputPath": { "outputPath": {
"type": "string", "type": "string",
"description": "The output path of the generated files.", "description": "The output path of the generated files.",
"x-completion-type": "directory" "x-completion-type": "directory",
"x-priority": "important"
}, },
"tsConfig": { "tsConfig": {
"type": "string", "type": "string",
"description": "The path to the Typescript configuration file.", "description": "The path to the Typescript configuration file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json" "x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
}, },
"swcrc": { "swcrc": {
"type": "string", "type": "string",

View File

@ -1,6 +1,7 @@
import { ExecutorContext } from '@nx/devkit'; import { ExecutorContext, readJsonFile } from '@nx/devkit';
import { assetGlobsToFiles, FileInputOutput } from '../../utils/assets/assets'; import { assetGlobsToFiles, FileInputOutput } from '../../utils/assets/assets';
import { removeSync } from 'fs-extra'; import { removeSync } from 'fs-extra';
import { sync as globSync } from 'fast-glob';
import { dirname, join, relative, resolve } from 'path'; import { dirname, join, relative, resolve } from 'path';
import { copyAssets } from '../../utils/assets'; import { copyAssets } from '../../utils/assets';
import { checkDependencies } from '../../utils/check-dependencies'; import { checkDependencies } from '../../utils/check-dependencies';
@ -135,6 +136,13 @@ export async function* swcExecutor(
); );
} }
function determineModuleFormatFromSwcrc(
absolutePathToSwcrc: string
): 'cjs' | 'esm' {
const swcrc = readJsonFile(absolutePathToSwcrc);
return swcrc.module?.type?.startsWith('es') ? 'esm' : 'cjs';
}
if (options.watch) { if (options.watch) {
let disposeFn: () => void; let disposeFn: () => void;
process.on('SIGINT', () => disposeFn()); process.on('SIGINT', () => disposeFn());
@ -145,7 +153,13 @@ export async function* swcExecutor(
const packageJsonResult = await copyPackageJson( const packageJsonResult = await copyPackageJson(
{ {
...options, ...options,
skipTypings: !options.skipTypeCheck, additionalEntryPoints: createEntryPoints(options, context),
format: [
determineModuleFormatFromSwcrc(options.swcCliOptions.swcrcPath),
],
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
}, },
context context
); );
@ -161,8 +175,13 @@ export async function* swcExecutor(
await copyPackageJson( await copyPackageJson(
{ {
...options, ...options,
generateExportsField: true, additionalEntryPoints: createEntryPoints(options, context),
skipTypings: !options.skipTypeCheck, format: [
determineModuleFormatFromSwcrc(options.swcCliOptions.swcrcPath),
],
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
extraDependencies: swcHelperDependency ? [swcHelperDependency] : [], extraDependencies: swcHelperDependency ? [swcHelperDependency] : [],
}, },
context context
@ -183,4 +202,14 @@ function removeTmpSwcrc(swcrcPath: string) {
} }
} }
function createEntryPoints(
options: { additionalEntryPoints?: string[] },
context: ExecutorContext
): string[] {
if (!options.additionalEntryPoints?.length) return [];
return globSync(options.additionalEntryPoints, {
cwd: context.root,
});
}
export default swcExecutor; export default swcExecutor;

View File

@ -10,7 +10,23 @@
"type": "string", "type": "string",
"description": "The name of the main entry-point file.", "description": "The name of the main entry-point file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "main@(.js|.ts|.jsx|.tsx)" "x-completion-glob": "main@(.js|.ts|.jsx|.tsx)",
"x-priority": "important"
},
"generateExportsField": {
"type": "boolean",
"alias": "exports",
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundles.",
"default": false,
"x-priority": "important"
},
"additionalEntryPoints": {
"type": "array",
"description": "Additional entry-points to add to exports field in the package.json file.",
"items": {
"type": "string"
},
"x-priority": "important"
}, },
"rootDir": { "rootDir": {
"type": "string", "type": "string",
@ -19,13 +35,15 @@
"outputPath": { "outputPath": {
"type": "string", "type": "string",
"description": "The output path of the generated files.", "description": "The output path of the generated files.",
"x-completion-type": "directory" "x-completion-type": "directory",
"x-priority": "important"
}, },
"tsConfig": { "tsConfig": {
"type": "string", "type": "string",
"description": "The path to the Typescript configuration file.", "description": "The path to the Typescript configuration file.",
"x-completion-type": "file", "x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json" "x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
}, },
"assets": { "assets": {
"type": "array", "type": "array",

View File

@ -4,6 +4,10 @@ import type { BatchExecutorTaskResult } from 'nx/src/config/misc-interfaces';
import { getLastValueFromAsyncIterableIterator } from 'nx/src/utils/async-iterator'; import { getLastValueFromAsyncIterableIterator } from 'nx/src/utils/async-iterator';
import { updatePackageJson } from '../../utils/package-json/update-package-json'; import { updatePackageJson } from '../../utils/package-json/update-package-json';
import type { ExecutorOptions } from '../../utils/schema'; import type { ExecutorOptions } from '../../utils/schema';
import {
createEntryPoints,
determineModuleFormatFromTsConfig,
} from './tsc.impl';
import { import {
TypescripCompilationLogger, TypescripCompilationLogger,
TypescriptCompilationResult, TypescriptCompilationResult,
@ -81,7 +85,14 @@ export async function* tscBatchExecutor(
const taskInfo = tsConfigTaskInfoMap[tsConfig]; const taskInfo = tsConfigTaskInfoMap[tsConfig];
taskInfo.assetsHandler.processAllAssetsOnceSync(); taskInfo.assetsHandler.processAllAssetsOnceSync();
updatePackageJson( updatePackageJson(
taskInfo.options, {
...taskInfo.options,
additionalEntryPoints: createEntryPoints(taskInfo.options, context),
format: [determineModuleFormatFromTsConfig(tsConfig)],
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
},
taskInfo.context, taskInfo.context,
taskInfo.projectGraphNode, taskInfo.projectGraphNode,
taskInfo.buildableProjectNodeDependencies taskInfo.buildableProjectNodeDependencies
@ -114,7 +125,14 @@ export async function* tscBatchExecutor(
(changedTaskInfos: TaskInfo[]) => { (changedTaskInfos: TaskInfo[]) => {
for (const t of changedTaskInfos) { for (const t of changedTaskInfos) {
updatePackageJson( updatePackageJson(
t.options, {
...t.options,
additionalEntryPoints: createEntryPoints(t.options, context),
format: [determineModuleFormatFromTsConfig(t.options.tsConfig)],
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
},
t.context, t.context,
t.projectGraphNode, t.projectGraphNode,
t.buildableProjectNodeDependencies t.buildableProjectNodeDependencies

View File

@ -1,3 +1,5 @@
import * as ts from 'typescript';
import { sync as globSync } from 'fast-glob';
import { ExecutorContext } from '@nx/devkit'; import { ExecutorContext } from '@nx/devkit';
import type { TypeScriptCompilationOptions } from '@nx/workspace/src/utilities/typescript/compilation'; import type { TypeScriptCompilationOptions } from '@nx/workspace/src/utilities/typescript/compilation';
import { CopyAssetsHandler } from '../../utils/assets/copy-assets-handler'; import { CopyAssetsHandler } from '../../utils/assets/copy-assets-handler';
@ -16,6 +18,23 @@ import { ExecutorOptions, NormalizedExecutorOptions } from '../../utils/schema';
import { compileTypeScriptFiles } from '../../utils/typescript/compile-typescript-files'; import { compileTypeScriptFiles } from '../../utils/typescript/compile-typescript-files';
import { watchForSingleFileChanges } from '../../utils/watch-for-single-file-changes'; import { watchForSingleFileChanges } from '../../utils/watch-for-single-file-changes';
import { getCustomTrasformersFactory, normalizeOptions } from './lib'; import { getCustomTrasformersFactory, normalizeOptions } from './lib';
import { readTsConfig } from '../../utils/typescript/ts-config';
export function determineModuleFormatFromTsConfig(
absolutePathToTsConfig: string
): 'cjs' | 'esm' {
const tsConfig = readTsConfig(absolutePathToTsConfig);
if (
tsConfig.options.module === ts.ModuleKind.ES2015 ||
tsConfig.options.module === ts.ModuleKind.ES2020 ||
tsConfig.options.module === ts.ModuleKind.ES2022 ||
tsConfig.options.module === ts.ModuleKind.ESNext
) {
return 'esm';
} else {
return 'cjs';
}
}
export function createTypeScriptCompilationOptions( export function createTypeScriptCompilationOptions(
normalizedOptions: NormalizedExecutorOptions, normalizedOptions: NormalizedExecutorOptions,
@ -90,7 +109,19 @@ export async function* tscExecutor(
tsCompilationOptions, tsCompilationOptions,
async () => { async () => {
await assetHandler.processAllAssetsOnce(); await assetHandler.processAllAssetsOnce();
updatePackageJson(options, context, target, dependencies); updatePackageJson(
{
...options,
additionalEntryPoints: createEntryPoints(options, context),
format: [determineModuleFormatFromTsConfig(options.tsConfig)],
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
},
context,
target,
dependencies
);
postProcessInlinedDependencies( postProcessInlinedDependencies(
tsCompilationOptions.outputPath, tsCompilationOptions.outputPath,
tsCompilationOptions.projectRoot, tsCompilationOptions.projectRoot,
@ -106,7 +137,20 @@ export async function* tscExecutor(
context.projectName, context.projectName,
options.projectRoot, options.projectRoot,
'package.json', 'package.json',
() => updatePackageJson(options, context, target, dependencies) () =>
updatePackageJson(
{
...options,
additionalEntryPoints: createEntryPoints(options, context),
// As long as d.ts files match their .js counterparts, we don't need to emit them.
// TSC can match them correctly based on file names.
skipTypings: true,
format: [determineModuleFormatFromTsConfig(options.tsConfig)],
},
context,
target,
dependencies
)
); );
const handleTermination = async (exitCode: number) => { const handleTermination = async (exitCode: number) => {
await typescriptCompilation.close(); await typescriptCompilation.close();
@ -121,4 +165,14 @@ export async function* tscExecutor(
return yield* typescriptCompilation.iterator; return yield* typescriptCompilation.iterator;
} }
export function createEntryPoints(
options: { additionalEntryPoints?: string[] },
context: ExecutorContext
): string[] {
if (!options.additionalEntryPoints?.length) return [];
return globSync(options.additionalEntryPoints, {
cwd: context.root,
});
}
export default tscExecutor; export default tscExecutor;

View File

@ -1,11 +1,11 @@
import { dirname, relative } from 'path'; import { dirname, relative } from 'path';
import { normalizePath } from 'nx/src/utils/path'; import { normalizePath } from 'nx/src/utils/path';
export function getMainFileDirRelativeToProjectRoot( export function getRelativeDirectoryToProjectRoot(
main: string, file: string,
projectRoot: string projectRoot: string
): string { ): string {
const mainFileDir = dirname(main); const dir = dirname(file);
const relativeDir = normalizePath(relative(projectRoot, mainFileDir)); const relativeDir = normalizePath(relative(projectRoot, dir));
return relativeDir === '' ? `./` : `./${relativeDir}/`; return relativeDir === '' ? `./` : `./${relativeDir}/`;
} }

View File

@ -30,6 +30,7 @@ describe('getUpdatedPackageJsonContent', () => {
expect(json).toEqual({ expect(json).toEqual({
name: 'test', name: 'test',
main: './src/index.js', main: './src/index.js',
type: 'commonjs',
types: './src/index.d.ts', types: './src/index.d.ts',
version: '0.0.1', version: '0.0.1',
}); });
@ -99,66 +100,11 @@ describe('getUpdatedPackageJsonContent', () => {
expect(json).toEqual({ expect(json).toEqual({
name: 'test', name: 'test',
main: './src/index.js', main: './src/index.js',
type: 'commonjs',
version: '0.0.1', version: '0.0.1',
}); });
}); });
it('should support generated exports field', () => {
const json = getUpdatedPackageJsonContent(
{
name: 'test',
version: '0.0.1',
},
{
main: 'proj/src/index.ts',
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['esm'],
generateExportsField: true,
}
);
expect(json).toEqual({
name: 'test',
type: 'module',
main: './src/index.js',
module: './src/index.js',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': { import: './src/index.js' },
},
});
});
it('should support different CJS file extension', () => {
const json = getUpdatedPackageJsonContent(
{
name: 'test',
version: '0.0.1',
},
{
main: 'proj/src/index.ts',
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['esm', 'cjs'],
outputFileExtensionForCjs: '.cjs',
generateExportsField: true,
}
);
expect(json).toEqual({
name: 'test',
main: './src/index.cjs',
module: './src/index.js',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': { require: './src/index.cjs', import: './src/index.js' },
},
});
});
it('should not set types when { skipTypings: true }', () => { it('should not set types when { skipTypings: true }', () => {
const json = getUpdatedPackageJsonContent( const json = getUpdatedPackageJsonContent(
{ {
@ -176,68 +122,181 @@ describe('getUpdatedPackageJsonContent', () => {
expect(json).toEqual({ expect(json).toEqual({
name: 'test', name: 'test',
main: './src/index.js', main: './src/index.js',
type: 'commonjs',
version: '0.0.1', version: '0.0.1',
}); });
}); });
it('should support different exports field shape', () => { describe('generateExportsField: true', () => {
// exports: string it('should add ESM exports', () => {
expect( const json = getUpdatedPackageJsonContent(
getUpdatedPackageJsonContent(
{ {
name: 'test', name: 'test',
version: '0.0.1', version: '0.0.1',
exports: './custom.js',
}, },
{ {
main: 'proj/src/index.ts', main: 'proj/src/index.ts',
outputPath: 'dist/proj', outputPath: 'dist/proj',
projectRoot: 'proj', projectRoot: 'proj',
format: ['esm', 'cjs'], format: ['esm'],
outputFileExtensionForCjs: '.cjs',
generateExportsField: true, generateExportsField: true,
} }
) );
).toEqual({
name: 'test', expect(json).toEqual({
main: './src/index.cjs', name: 'test',
module: './src/index.js', type: 'module',
types: './src/index.d.ts', main: './src/index.js',
version: '0.0.1', module: './src/index.js',
exports: './custom.js', types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': './src/index.js',
'./package.json': './package.json',
},
});
}); });
// exports: { '.': string } it('should add CJS exports', () => {
expect( const json = getUpdatedPackageJsonContent(
getUpdatedPackageJsonContent(
{ {
name: 'test', name: 'test',
version: '0.0.1', version: '0.0.1',
exports: { },
'.': './custom.js', {
main: 'proj/src/index.ts',
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['cjs'],
outputFileExtensionForCjs: '.cjs',
generateExportsField: true,
}
);
expect(json).toEqual({
name: 'test',
main: './src/index.cjs',
types: './src/index.d.ts',
version: '0.0.1',
type: 'commonjs',
exports: {
'.': './src/index.cjs',
'./package.json': './package.json',
},
});
});
it('should add additional entry-points into package.json', () => {
// CJS only
expect(
getUpdatedPackageJsonContent(
{
name: 'test',
version: '0.0.1',
}, },
{
main: 'proj/src/index.ts',
additionalEntryPoints: [
'proj/src/foo.ts',
'proj/src/bar.ts',
'proj/migrations.json',
],
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['cjs'],
generateExportsField: true,
}
)
).toEqual({
name: 'test',
main: './src/index.js',
type: 'commonjs',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': './src/index.js',
'./foo': './src/foo.js',
'./bar': './src/bar.js',
'./package.json': './package.json',
'./migrations.json': './migrations.json',
}, },
{ });
main: 'proj/src/index.ts',
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['esm', 'cjs'],
outputFileExtensionForCjs: '.cjs',
generateExportsField: true,
}
)
).toEqual({
name: 'test',
main: './src/index.cjs',
module: './src/index.js',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': './custom.js',
},
});
// exports: { './custom': string } // ESM only
expect(
getUpdatedPackageJsonContent(
{
name: 'test',
version: '0.0.1',
},
{
main: 'proj/src/index.ts',
additionalEntryPoints: ['proj/src/foo.ts', 'proj/src/bar.ts'],
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['esm'],
generateExportsField: true,
}
)
).toEqual({
name: 'test',
type: 'module',
main: './src/index.js',
module: './src/index.js',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': './src/index.js',
'./foo': './src/foo.js',
'./bar': './src/bar.js',
'./package.json': './package.json',
},
});
// Dual format
expect(
getUpdatedPackageJsonContent(
{
name: 'test',
version: '0.0.1',
},
{
main: 'proj/src/index.ts',
additionalEntryPoints: ['proj/src/foo.ts', 'proj/src/bar.ts'],
outputPath: 'dist/proj',
projectRoot: 'proj',
format: ['cjs', 'esm'],
outputFileExtensionForCjs: '.cjs',
generateExportsField: true,
}
)
).toEqual({
name: 'test',
main: './src/index.cjs',
module: './src/index.js',
types: './src/index.d.ts',
version: '0.0.1',
exports: {
'.': {
import: './src/index.js',
default: './src/index.cjs',
},
'./foo': {
import: './src/foo.js',
default: './src/foo.cjs',
},
'./bar': {
import: './src/bar.js',
default: './src/bar.cjs',
},
'./package.json': './package.json',
},
});
});
});
it('should support existing exports', () => {
// Merge additional exports from user
expect( expect(
getUpdatedPackageJsonContent( getUpdatedPackageJsonContent(
{ {
@ -265,8 +324,9 @@ describe('getUpdatedPackageJsonContent', () => {
exports: { exports: {
'.': { '.': {
import: './src/index.js', import: './src/index.js',
require: './src/index.cjs', default: './src/index.cjs',
}, },
'./package.json': './package.json',
'./custom': './custom.js', './custom': './custom.js',
}, },
}); });
@ -380,6 +440,7 @@ describe('updatePackageJson', () => {
{ {
"main": "./main.js", "main": "./main.js",
"name": "@org/lib1", "name": "@org/lib1",
"type": "commonjs",
"types": "./main.d.ts", "types": "./main.d.ts",
"version": "0.0.1", "version": "0.0.1",
} }
@ -441,6 +502,7 @@ describe('updatePackageJson', () => {
}, },
"main": "./main.js", "main": "./main.js",
"name": "@org/lib1", "name": "@org/lib1",
"type": "commonjs",
"types": "./main.d.ts", "types": "./main.d.ts",
"version": "0.0.3", "version": "0.0.3",
} }

View File

@ -5,6 +5,7 @@ import {
} from 'nx/src/plugins/js/lock-file/lock-file'; } from 'nx/src/plugins/js/lock-file/lock-file';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { createPackageJson } from 'nx/src/plugins/js/package-json/create-package-json'; import { createPackageJson } from 'nx/src/plugins/js/package-json/create-package-json';
import { import {
ExecutorContext, ExecutorContext,
getOutputsForTargetAndConfiguration, getOutputsForTargetAndConfiguration,
@ -16,25 +17,28 @@ import {
writeJsonFile, writeJsonFile,
} from '@nx/devkit'; } from '@nx/devkit';
import { DependentBuildableProjectNode } from '../buildable-libs-utils'; import { DependentBuildableProjectNode } from '../buildable-libs-utils';
import { basename, join, parse } from 'path'; import { basename, join, parse, relative } from 'path';
import { writeFileSync } from 'fs-extra'; import { writeFileSync } from 'fs-extra';
import { isNpmProject } from 'nx/src/project-graph/operators'; import { isNpmProject } from 'nx/src/project-graph/operators';
import { fileExists } from 'nx/src/utils/fileutils'; import { fileExists } from 'nx/src/utils/fileutils';
import type { PackageJson } from 'nx/src/utils/package-json'; import type { PackageJson } from 'nx/src/utils/package-json';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { readProjectFileMapCache } from 'nx/src/project-graph/nx-deps-cache'; import { readProjectFileMapCache } from 'nx/src/project-graph/nx-deps-cache';
import * as fastGlob from 'fast-glob';
import { getMainFileDirRelativeToProjectRoot } from '../get-main-file-dir'; import { getRelativeDirectoryToProjectRoot } from '../get-main-file-dir';
export type SupportedFormat = 'cjs' | 'esm'; export type SupportedFormat = 'cjs' | 'esm';
export interface UpdatePackageJsonOption { export interface UpdatePackageJsonOption {
projectRoot: string; projectRoot: string;
main: string; main: string;
additionalEntryPoints?: string[];
format?: SupportedFormat[]; format?: SupportedFormat[];
outputPath: string; outputPath: string;
outputFileName?: string; outputFileName?: string;
outputFileExtensionForCjs?: `.${string}`; outputFileExtensionForCjs?: `.${string}`;
outputFileExtensionForEsm?: `.${string}`;
skipTypings?: boolean; skipTypings?: boolean;
generateExportsField?: boolean; generateExportsField?: boolean;
excludeLibsInPackageJson?: boolean; excludeLibsInPackageJson?: boolean;
@ -159,6 +163,50 @@ function addMissingDependencies(
}); });
} }
interface Exports {
'.': string;
[name: string]: string;
}
export function getExports(
options: Pick<
UpdatePackageJsonOption,
'main' | 'projectRoot' | 'outputFileName' | 'additionalEntryPoints'
> & {
fileExt: string;
}
): Exports {
const mainFile = options.outputFileName
? options.outputFileName.replace(/\.[tj]s$/, '')
: basename(options.main).replace(/\.[tj]s$/, '');
const relativeMainFileDir = options.outputFileName
? './'
: getRelativeDirectoryToProjectRoot(options.main, options.projectRoot);
const exports: Exports = {
'.': relativeMainFileDir + mainFile + options.fileExt,
};
if (options.additionalEntryPoints) {
const jsRegex = /\.[jt]sx?$/;
for (const file of options.additionalEntryPoints) {
const { ext: fileExt, name: fileName } = parse(file);
const relativeDir = getRelativeDirectoryToProjectRoot(
file,
options.projectRoot
);
const sourceFilePath = relativeDir + fileName;
const entryFilepath = sourceFilePath.replace(/^\.\/src\//, './');
const isJsFile = jsRegex.test(fileExt);
exports[isJsFile ? entryFilepath : entryFilepath + fileExt] =
sourceFilePath + (isJsFile ? options.fileExt : fileExt);
}
}
return exports;
}
export function getUpdatedPackageJsonContent( export function getUpdatedPackageJsonContent(
packageJson: PackageJson, packageJson: PackageJson,
options: UpdatePackageJsonOption options: UpdatePackageJsonOption
@ -167,65 +215,66 @@ export function getUpdatedPackageJsonContent(
const hasCjsFormat = !options.format || options.format?.includes('cjs'); const hasCjsFormat = !options.format || options.format?.includes('cjs');
const hasEsmFormat = options.format?.includes('esm'); const hasEsmFormat = options.format?.includes('esm');
const mainFile = basename(options.main).replace(/\.[tj]s$/, ''); if (options.generateExportsField) {
const relativeMainFileDir = getMainFileDirRelativeToProjectRoot( packageJson.exports =
options.main, typeof packageJson.exports === 'string' ? {} : { ...packageJson.exports };
options.projectRoot packageJson.exports['./package.json'] = './package.json';
); }
const typingsFile = `${relativeMainFileDir}${mainFile}.d.ts`;
const exports =
typeof packageJson.exports === 'string'
? packageJson.exports
: {
'.': {},
...packageJson.exports,
};
const mainJsFile =
options.outputFileName ?? `${relativeMainFileDir}${mainFile}.js`;
if (hasEsmFormat) { if (hasEsmFormat) {
// Unofficial field for backwards compat. const esmExports = getExports({
packageJson.module ??= mainJsFile; ...options,
fileExt: options.outputFileExtensionForEsm ?? '.js',
});
packageJson.module = esmExports['.'];
if (!hasCjsFormat) { if (!hasCjsFormat) {
packageJson.type = 'module'; packageJson.type = 'module';
packageJson.main ??= mainJsFile; packageJson.main ??= esmExports['.'];
} }
if (typeof exports !== 'string') { if (options.generateExportsField) {
if (typeof exports['.'] !== 'string') { for (const [exportEntry, filePath] of Object.entries(esmExports)) {
exports['.']['import'] ??= mainJsFile; packageJson.exports[exportEntry] = hasCjsFormat
} else if (!hasCjsFormat) { ? { import: filePath }
exports['.'] ??= mainJsFile; : filePath;
} }
} }
} }
// CJS output may have .cjs or .js file extensions. // CJS output may have .cjs or .js file extensions.
// Bundlers like rollup and esbuild supports .cjs for CJS and .js for ESM. // Bundlers like rollup and esbuild supports .cjs for CJS and .js for ESM.
// Bundlers/Compilers like webpack, tsc, swc do not have different file extensions. // Bundlers/Compilers like webpack, tsc, swc do not have different file extensions (unless you use .mts or .cts in source).
if (hasCjsFormat) { if (hasCjsFormat) {
const { dir, name } = parse(mainJsFile); const cjsExports = getExports({
const cjsMain = `${dir ? dir : '.'}/${name}${ ...options,
options.outputFileExtensionForCjs ?? '.js' fileExt: options.outputFileExtensionForCjs ?? '.js',
}`; });
packageJson.main ??= cjsMain;
if (typeof exports !== 'string') { packageJson.main = cjsExports['.'];
if (typeof exports['.'] !== 'string') { if (!hasEsmFormat) {
exports['.']['require'] ??= cjsMain; packageJson.type = 'commonjs';
} else if (!hasEsmFormat) { }
exports['.'] ??= cjsMain;
if (options.generateExportsField) {
for (const [exportEntry, filePath] of Object.entries(cjsExports)) {
if (hasEsmFormat) {
packageJson.exports[exportEntry]['default'] ??= filePath;
} else {
packageJson.exports[exportEntry] = filePath;
}
} }
} }
} }
if (options.generateExportsField) {
packageJson.exports = exports;
}
if (!options.skipTypings) { if (!options.skipTypings) {
const mainFile = basename(options.main).replace(/\.[tj]s$/, '');
const relativeMainFileDir = getRelativeDirectoryToProjectRoot(
options.main,
options.projectRoot
);
const typingsFile = `${relativeMainFileDir}${mainFile}.d.ts`;
packageJson.types = packageJson.types ?? typingsFile; packageJson.types = packageJson.types ?? typingsFile;
} }

View File

@ -38,6 +38,8 @@ export interface ExecutorOptions {
rootDir?: string; rootDir?: string;
outputPath: string; outputPath: string;
tsConfig: string; tsConfig: string;
generateExportsField?: boolean;
additionalEntryPoints?: string[];
swcrc?: string; swcrc?: string;
watch: boolean; watch: boolean;
clean?: boolean; clean?: boolean;

View File

@ -20,7 +20,9 @@ function getSwcCmd(
// TODO(jack): clean this up when we remove inline module support // TODO(jack): clean this up when we remove inline module support
// Handle root project // Handle root project
srcPath === '.' ? 'src' : srcPath srcPath === '.' ? 'src' : srcPath
} -d ${destPath} --config-file=${swcrcPath}`; } -d ${
srcPath === '.' ? `${destPath}/src` : destPath
} --config-file=${swcrcPath}`;
return watch ? swcCmd.concat(' --watch') : swcCmd; return watch ? swcCmd.concat(' --watch') : swcCmd;
} }

View File

@ -6,7 +6,7 @@ import { ensureTypescript } from './ensure-typescript';
let tsModule: typeof import('typescript'); let tsModule: typeof import('typescript');
export function readTsConfig(tsConfigPath: string) { export function readTsConfig(tsConfigPath: string): ts.ParsedCommandLine {
if (!tsModule) { if (!tsModule) {
tsModule = require('typescript'); tsModule = require('typescript');
} }