Skip to content

Commit 407c0be

Browse files
Build modern CommonJS and support package.json exports (#336)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent e77b9c8 commit 407c0be

File tree

6 files changed

+156
-43
lines changed

6 files changed

+156
-43
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bob-the-bundler": patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`get-tsconfig@^4.8.1` ↗︎](https://www.npmjs.com/package/get-tsconfig/v/4.8.1) (to `dependencies`)

.changeset/thin-mails-clap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'bob-the-bundler': major
3+
---
4+
5+
Build modern CommonJS and support package.json exports

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"consola": "^3.0.0",
5252
"execa": "^9.0.0",
5353
"fs-extra": "^11.1.0",
54+
"get-tsconfig": "^4.8.1",
5455
"globby": "^14.0.0",
5556
"js-yaml": "^4.1.0",
5657
"lodash.get": "^4.4.2",

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/build.ts

Lines changed: 127 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path';
33
import { type ConsolaInstance } from 'consola';
44
import { execa } from 'execa';
55
import fse from 'fs-extra';
6+
import { getTsconfig, parseTsconfig } from 'get-tsconfig';
67
import { globby } from 'globby';
78
import get from 'lodash.get';
89
import pLimit from 'p-limit';
@@ -41,21 +42,10 @@ const filesToExcludeFromDist = [
4142
'**/temp',
4243
];
4344

44-
const moduleMappings = {
45-
esm: 'es2022',
46-
cjs: 'commonjs',
47-
} as const;
48-
49-
function typeScriptCompilerOptions(target: 'esm' | 'cjs'): Record<string, unknown> {
50-
return {
51-
module: moduleMappings[target],
52-
sourceMap: false,
53-
inlineSourceMap: false,
54-
};
55-
}
56-
5745
function compilerOptionsToArgs(options: Record<string, unknown>): string[] {
58-
return Object.entries(options).flatMap(([key, value]) => [`--${key}`, `${value}`]);
46+
return Object.entries(options)
47+
.filter(([, value]) => !!value)
48+
.flatMap(([key, value]) => [`--${key}`, `${value}`]);
5949
}
6050

6151
function assertTypeScriptBuildResult(
@@ -70,36 +60,62 @@ function assertTypeScriptBuildResult(
7060

7161
async function buildTypeScript(
7262
buildPath: string,
73-
options: { cwd: string; tsconfig?: string; incremental?: boolean },
63+
options: {
64+
cwd: string;
65+
tsconfig?: string;
66+
incremental?: boolean;
67+
},
7468
reporter: ConsolaInstance,
7569
) {
76-
let tsconfig = options.tsconfig;
77-
if (!tsconfig && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) {
78-
tsconfig = join(options.cwd, DEFAULT_TS_BUILD_CONFIG);
70+
let project = options.tsconfig;
71+
if (!project && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) {
72+
project = join(options.cwd, DEFAULT_TS_BUILD_CONFIG);
7973
}
80-
assertTypeScriptBuildResult(
81-
await execa('npx', [
82-
'tsc',
83-
...(tsconfig ? ['--project', tsconfig] : []),
84-
...compilerOptionsToArgs(typeScriptCompilerOptions('esm')),
85-
...(options.incremental ? ['--incremental'] : []),
86-
'--outDir',
87-
join(buildPath, 'esm'),
88-
]),
89-
reporter,
90-
);
9174

92-
assertTypeScriptBuildResult(
93-
await execa('npx', [
94-
'tsc',
95-
...(tsconfig ? ['--project', tsconfig] : []),
96-
...compilerOptionsToArgs(typeScriptCompilerOptions('cjs')),
97-
...(options.incremental ? ['--incremental'] : []),
98-
'--outDir',
99-
join(buildPath, 'cjs'),
100-
]),
101-
reporter,
102-
);
75+
const tsconfig = project ? parseTsconfig(project) : getTsconfig(options.cwd)?.config;
76+
77+
const moduleResolution = (tsconfig?.compilerOptions?.moduleResolution || '').toLowerCase();
78+
const isModernNodeModuleResolution = ['node16', 'nodenext'].includes(moduleResolution);
79+
const isOldNodeModuleResolution = ['classic', 'node', 'node10'].includes(moduleResolution);
80+
if (moduleResolution && !isOldNodeModuleResolution && !isModernNodeModuleResolution) {
81+
throw new Error(
82+
`'moduleResolution' option '${moduleResolution}' cannot be used to build CommonJS"`,
83+
);
84+
}
85+
86+
async function build(out: PackageJsonType) {
87+
const revertPackageJsonsType = await setPackageJsonsType(
88+
{ cwd: options.cwd, ignore: [...filesToExcludeFromDist, ...(tsconfig?.exclude || [])] },
89+
out,
90+
);
91+
try {
92+
assertTypeScriptBuildResult(
93+
await execa('npx', [
94+
'tsc',
95+
...compilerOptionsToArgs({
96+
project,
97+
module: isModernNodeModuleResolution
98+
? moduleResolution // match module with moduleResolution for modern node (nodenext and node16)
99+
: out === 'module'
100+
? 'es2022'
101+
: isOldNodeModuleResolution
102+
? 'commonjs' // old commonjs
103+
: 'node16', // modern commonjs
104+
sourceMap: false,
105+
inlineSourceMap: false,
106+
incremental: options.incremental,
107+
outDir: out === 'module' ? join(buildPath, 'esm') : join(buildPath, 'cjs'),
108+
}),
109+
]),
110+
reporter,
111+
);
112+
} finally {
113+
await revertPackageJsonsType();
114+
}
115+
}
116+
117+
await build('module');
118+
await build('commonjs');
103119
}
104120

105121
export const buildCommand = createCommand<
@@ -479,6 +495,77 @@ export function validatePackageJson(
479495
}
480496
}
481497

498+
type PackageJsonType = 'module' | 'commonjs';
499+
500+
/**
501+
* Sets the {@link cwd workspaces} package.json(s) `"type"` field to the defined {@link type}
502+
* returning a "revert" function which puts the original `"type"` back.
503+
*
504+
* @returns A revert function that reverts the original value of the `"type"` field.
505+
*/
506+
async function setPackageJsonsType(
507+
{ cwd, ignore }: { cwd: string; ignore: string[] },
508+
type: PackageJsonType,
509+
): Promise<() => Promise<void>> {
510+
const rootPkgJsonPath = join(cwd, 'package.json');
511+
const rootContents = await fse.readFile(rootPkgJsonPath, 'utf8');
512+
const rootPkg = JSON.parse(rootContents);
513+
const workspaces = await getWorkspaces(rootPkg);
514+
const isSinglePackage = workspaces === null;
515+
516+
const reverts: (() => Promise<void>)[] = [];
517+
518+
for (const pkgJsonPath of [
519+
// we also want to modify the root package.json TODO: do we in single package repos?
520+
rootPkgJsonPath,
521+
...(isSinglePackage
522+
? []
523+
: await globby(
524+
workspaces.map((w: string) => w + '/package.json'),
525+
{ cwd, absolute: true, ignore },
526+
)),
527+
]) {
528+
const contents =
529+
pkgJsonPath === rootPkgJsonPath
530+
? // no need to re-read the root package.json
531+
rootContents
532+
: await fse.readFile(pkgJsonPath, 'utf8');
533+
const endsWithNewline = contents.endsWith('\n');
534+
535+
const pkg = JSON.parse(contents);
536+
if (pkg.type != null && pkg.type !== 'commonjs' && pkg.type !== 'module') {
537+
throw new Error(`Invalid "type" property value "${pkg.type}" in ${pkgJsonPath}`);
538+
}
539+
540+
const originalPkg = { ...pkg };
541+
const differentType =
542+
(pkg.type ||
543+
// default when the type is not defined
544+
'commonjs') !== type;
545+
546+
// change only if the provided type is different
547+
if (differentType) {
548+
pkg.type = type;
549+
await fse.writeFile(
550+
pkgJsonPath,
551+
JSON.stringify(pkg, null, ' ') + (endsWithNewline ? '\n' : ''),
552+
);
553+
554+
// revert change, of course only if we changed something
555+
reverts.push(async () => {
556+
await fse.writeFile(
557+
pkgJsonPath,
558+
JSON.stringify(originalPkg, null, ' ') + (endsWithNewline ? '\n' : ''),
559+
);
560+
});
561+
}
562+
}
563+
564+
return async function revert() {
565+
await Promise.all(reverts.map(r => r()));
566+
};
567+
}
568+
482569
async function executeCopy(sourcePath: string, destPath: string) {
483570
await fse.mkdirp(dirname(destPath));
484571
await fse.copyFile(sourcePath, destPath);

test/integration.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ it('can build a monorepo project', async () => {
204204
__exportStar(require("./foo.js"), exports);
205205
exports.b = 'SUP' + foo_js_1.b;
206206
function foo() {
207-
return Promise.resolve().then(() => require('./foo.js'));
207+
return import('./foo.js');
208208
}
209209
`);
210210
expect(await fse.readFile(files.b['typings/index.d.ts'], 'utf8')).toMatchInlineSnapshot(`
@@ -355,7 +355,7 @@ it('can build an esm only project', async () => {
355355
`);
356356

357357
expect(await fse.readFile(indexJsFilePath, 'utf8')).toMatchInlineSnapshot(
358-
'export var someNumber = 1;',
358+
`export var someNumber = 1;`,
359359
);
360360
expect(await fse.readFile(indexDtsFilePath, 'utf8')).toMatchInlineSnapshot(
361361
'export declare const someNumber = 1;',
@@ -552,7 +552,7 @@ it('can build a monorepo pnpm project', async () => {
552552
__exportStar(require("./foo.js"), exports);
553553
exports.b = 'SUP' + foo_js_1.b;
554554
function foo() {
555-
return Promise.resolve().then(() => require('./foo.js'));
555+
return import('./foo.js');
556556
}
557557
`);
558558
expect(await fse.readFile(files.b['typings/index.d.ts'], 'utf8')).toMatchInlineSnapshot(`

0 commit comments

Comments
 (0)