diff --git a/.changeset/bob-the-bundler-336-dependencies.md b/.changeset/bob-the-bundler-336-dependencies.md new file mode 100644 index 00000000..6a31e185 --- /dev/null +++ b/.changeset/bob-the-bundler-336-dependencies.md @@ -0,0 +1,5 @@ +--- +"bob-the-bundler": patch +--- +dependencies updates: + - Added dependency [`get-tsconfig@^4.8.1` ↗︎](https://www.npmjs.com/package/get-tsconfig/v/4.8.1) (to `dependencies`) diff --git a/.changeset/thin-mails-clap.md b/.changeset/thin-mails-clap.md new file mode 100644 index 00000000..2a0dbe56 --- /dev/null +++ b/.changeset/thin-mails-clap.md @@ -0,0 +1,5 @@ +--- +'bob-the-bundler': major +--- + +Build modern CommonJS and support package.json exports diff --git a/package.json b/package.json index 8d0e8361..dc33729c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "consola": "^3.0.0", "execa": "^9.0.0", "fs-extra": "^11.1.0", + "get-tsconfig": "^4.8.1", "globby": "^14.0.0", "js-yaml": "^4.1.0", "lodash.get": "^4.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f9fed19..cd711e19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: fs-extra: specifier: ^11.1.0 version: 11.3.0 + get-tsconfig: + specifier: ^4.8.1 + version: 4.8.1 globby: specifier: ^14.0.0 version: 14.0.2 @@ -1160,6 +1163,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.8.1: + resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1781,6 +1787,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -3439,6 +3448,10 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.2.7 + get-tsconfig@4.8.1: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4033,6 +4046,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} resolve@1.22.10: diff --git a/src/commands/build.ts b/src/commands/build.ts index 2c04686d..8433fe20 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path'; import { type ConsolaInstance } from 'consola'; import { execa } from 'execa'; import fse from 'fs-extra'; +import { getTsconfig, parseTsconfig } from 'get-tsconfig'; import { globby } from 'globby'; import get from 'lodash.get'; import pLimit from 'p-limit'; @@ -41,21 +42,10 @@ const filesToExcludeFromDist = [ '**/temp', ]; -const moduleMappings = { - esm: 'es2022', - cjs: 'commonjs', -} as const; - -function typeScriptCompilerOptions(target: 'esm' | 'cjs'): Record { - return { - module: moduleMappings[target], - sourceMap: false, - inlineSourceMap: false, - }; -} - function compilerOptionsToArgs(options: Record): string[] { - return Object.entries(options).flatMap(([key, value]) => [`--${key}`, `${value}`]); + return Object.entries(options) + .filter(([, value]) => !!value) + .flatMap(([key, value]) => [`--${key}`, `${value}`]); } function assertTypeScriptBuildResult( @@ -70,36 +60,62 @@ function assertTypeScriptBuildResult( async function buildTypeScript( buildPath: string, - options: { cwd: string; tsconfig?: string; incremental?: boolean }, + options: { + cwd: string; + tsconfig?: string; + incremental?: boolean; + }, reporter: ConsolaInstance, ) { - let tsconfig = options.tsconfig; - if (!tsconfig && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) { - tsconfig = join(options.cwd, DEFAULT_TS_BUILD_CONFIG); + let project = options.tsconfig; + if (!project && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) { + project = join(options.cwd, DEFAULT_TS_BUILD_CONFIG); } - assertTypeScriptBuildResult( - await execa('npx', [ - 'tsc', - ...(tsconfig ? ['--project', tsconfig] : []), - ...compilerOptionsToArgs(typeScriptCompilerOptions('esm')), - ...(options.incremental ? ['--incremental'] : []), - '--outDir', - join(buildPath, 'esm'), - ]), - reporter, - ); - assertTypeScriptBuildResult( - await execa('npx', [ - 'tsc', - ...(tsconfig ? ['--project', tsconfig] : []), - ...compilerOptionsToArgs(typeScriptCompilerOptions('cjs')), - ...(options.incremental ? ['--incremental'] : []), - '--outDir', - join(buildPath, 'cjs'), - ]), - reporter, - ); + const tsconfig = project ? parseTsconfig(project) : getTsconfig(options.cwd)?.config; + + const moduleResolution = (tsconfig?.compilerOptions?.moduleResolution || '').toLowerCase(); + const isModernNodeModuleResolution = ['node16', 'nodenext'].includes(moduleResolution); + const isOldNodeModuleResolution = ['classic', 'node', 'node10'].includes(moduleResolution); + if (moduleResolution && !isOldNodeModuleResolution && !isModernNodeModuleResolution) { + throw new Error( + `'moduleResolution' option '${moduleResolution}' cannot be used to build CommonJS"`, + ); + } + + async function build(out: PackageJsonType) { + const revertPackageJsonsType = await setPackageJsonsType( + { cwd: options.cwd, ignore: [...filesToExcludeFromDist, ...(tsconfig?.exclude || [])] }, + out, + ); + try { + assertTypeScriptBuildResult( + await execa('npx', [ + 'tsc', + ...compilerOptionsToArgs({ + project, + module: isModernNodeModuleResolution + ? moduleResolution // match module with moduleResolution for modern node (nodenext and node16) + : out === 'module' + ? 'es2022' + : isOldNodeModuleResolution + ? 'commonjs' // old commonjs + : 'node16', // modern commonjs + sourceMap: false, + inlineSourceMap: false, + incremental: options.incremental, + outDir: out === 'module' ? join(buildPath, 'esm') : join(buildPath, 'cjs'), + }), + ]), + reporter, + ); + } finally { + await revertPackageJsonsType(); + } + } + + await build('module'); + await build('commonjs'); } export const buildCommand = createCommand< @@ -479,6 +495,77 @@ export function validatePackageJson( } } +type PackageJsonType = 'module' | 'commonjs'; + +/** + * Sets the {@link cwd workspaces} package.json(s) `"type"` field to the defined {@link type} + * returning a "revert" function which puts the original `"type"` back. + * + * @returns A revert function that reverts the original value of the `"type"` field. + */ +async function setPackageJsonsType( + { cwd, ignore }: { cwd: string; ignore: string[] }, + type: PackageJsonType, +): Promise<() => Promise> { + const rootPkgJsonPath = join(cwd, 'package.json'); + const rootContents = await fse.readFile(rootPkgJsonPath, 'utf8'); + const rootPkg = JSON.parse(rootContents); + const workspaces = await getWorkspaces(rootPkg); + const isSinglePackage = workspaces === null; + + const reverts: (() => Promise)[] = []; + + for (const pkgJsonPath of [ + // we also want to modify the root package.json TODO: do we in single package repos? + rootPkgJsonPath, + ...(isSinglePackage + ? [] + : await globby( + workspaces.map((w: string) => w + '/package.json'), + { cwd, absolute: true, ignore }, + )), + ]) { + const contents = + pkgJsonPath === rootPkgJsonPath + ? // no need to re-read the root package.json + rootContents + : await fse.readFile(pkgJsonPath, 'utf8'); + const endsWithNewline = contents.endsWith('\n'); + + const pkg = JSON.parse(contents); + if (pkg.type != null && pkg.type !== 'commonjs' && pkg.type !== 'module') { + throw new Error(`Invalid "type" property value "${pkg.type}" in ${pkgJsonPath}`); + } + + const originalPkg = { ...pkg }; + const differentType = + (pkg.type || + // default when the type is not defined + 'commonjs') !== type; + + // change only if the provided type is different + if (differentType) { + pkg.type = type; + await fse.writeFile( + pkgJsonPath, + JSON.stringify(pkg, null, ' ') + (endsWithNewline ? '\n' : ''), + ); + + // revert change, of course only if we changed something + reverts.push(async () => { + await fse.writeFile( + pkgJsonPath, + JSON.stringify(originalPkg, null, ' ') + (endsWithNewline ? '\n' : ''), + ); + }); + } + } + + return async function revert() { + await Promise.all(reverts.map(r => r())); + }; +} + async function executeCopy(sourcePath: string, destPath: string) { await fse.mkdirp(dirname(destPath)); await fse.copyFile(sourcePath, destPath); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index db0fb352..a8ff3324 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -204,7 +204,7 @@ it('can build a monorepo project', async () => { __exportStar(require("./foo.js"), exports); exports.b = 'SUP' + foo_js_1.b; function foo() { - return Promise.resolve().then(() => require('./foo.js')); + return import('./foo.js'); } `); expect(await fse.readFile(files.b['typings/index.d.ts'], 'utf8')).toMatchInlineSnapshot(` @@ -355,7 +355,7 @@ it('can build an esm only project', async () => { `); expect(await fse.readFile(indexJsFilePath, 'utf8')).toMatchInlineSnapshot( - 'export var someNumber = 1;', + `export var someNumber = 1;`, ); expect(await fse.readFile(indexDtsFilePath, 'utf8')).toMatchInlineSnapshot( 'export declare const someNumber = 1;', @@ -552,7 +552,7 @@ it('can build a monorepo pnpm project', async () => { __exportStar(require("./foo.js"), exports); exports.b = 'SUP' + foo_js_1.b; function foo() { - return Promise.resolve().then(() => require('./foo.js')); + return import('./foo.js'); } `); expect(await fse.readFile(files.b['typings/index.d.ts'], 'utf8')).toMatchInlineSnapshot(`