diff --git a/package.json b/package.json index 647b09d00..617d2c8e9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/egoist/tsup.git" }, "scripts": { - "dev": "npm run build-fast -- --watch", + "dev": "npm run build-fast -- --sourcemap --watch", "build": "tsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting", "prepublishOnly": "npm run build", "test": "npm run build && npm run test-only", diff --git a/src/plugin.ts b/src/plugin.ts index d42410a7f..7fda57a12 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,7 +4,7 @@ import { SourceMapConsumer, SourceMapGenerator, RawSourceMap } from 'source-map' import { Format, NormalizedOptions } from '.' import { outputFile } from './fs' import { Logger } from './log' -import { MaybePromise } from './utils' +import { MaybePromise, slash } from './utils' import { SourceMap } from 'rollup' export type ChunkInfo = { @@ -124,7 +124,8 @@ export class PluginContainer { .filter((file) => !file.path.endsWith('.map')) .map((file): ChunkInfo | AssetInfo => { if (isJS(file.path) || isCSS(file.path)) { - const relativePath = path.relative(process.cwd(), file.path) + // esbuild is using "/" as a separator in Windows as well + const relativePath = slash(path.relative(process.cwd(), file.path)) const meta = metafile?.outputs[relativePath] return { type: 'chunk', diff --git a/src/plugins/cjs-interop.ts b/src/plugins/cjs-interop.ts index eae475ec2..6fc1d6b81 100644 --- a/src/plugins/cjs-interop.ts +++ b/src/plugins/cjs-interop.ts @@ -1,26 +1,112 @@ +import type { + ExportDefaultExpression, + ModuleDeclaration, + ParseOptions, +} from '@swc/core' +import type { Visitor } from '@swc/core/Visitor' +import fs from 'fs/promises' +import path from 'path' +import { PrettyError } from '../errors' import { Plugin } from '../plugin' +import { localRequire } from '../utils' export const cjsInterop = (): Plugin => { return { name: 'cjs-interop', async renderChunk(code, info) { + const { entryPoint } = info if ( !this.options.cjsInterop || this.format !== 'cjs' || info.type !== 'chunk' || !/\.(js|cjs)$/.test(info.path) || - !info.entryPoint || - info.exports?.length !== 1 || - info.exports[0] !== 'default' + !entryPoint ) { return } + if (this.splitting) { + // there is exports metadata when cjs+splitting is set + if (info.exports?.length !== 1 || info.exports[0] !== 'default') return + } else { + const swc: typeof import('@swc/core') = localRequire('@swc/core') + const { Visitor }: typeof import('@swc/core/Visitor') = + localRequire('@swc/core/Visitor') + if (!swc || !Visitor) { + throw new PrettyError( + `@swc/core is required for cjsInterop when splitting is not enabled. Please install it with \`npm install @swc/core -D\`` + ) + } + + try { + const entrySource = await fs.readFile(entryPoint, { + encoding: 'utf8', + }) + const ast = await swc.parse(entrySource, getParseOptions(entryPoint)) + const visitor = createExportVisitor(Visitor) + visitor.visitProgram(ast) + + if ( + !visitor.hasExportDefaultExpression || + visitor.hasNonDefaultExportDeclaration + ) + return + } catch { + return + } + } + return { - code: code + '\nmodule.exports = exports.default;\n', + code: code + '\nmodule.exports=module.exports.default;\n', map: info.map, } }, } } + +function getParseOptions(filename: string): ParseOptions { + if (/\.([cm]?js|jsx)$/.test(filename)) + return { + syntax: 'ecmascript', + decorators: true, + ...(filename.endsWith('.jsx') ? { jsx: true } : null), + } + if (/\.([cm]?ts|tsx)$/.test(filename)) + return { + syntax: 'typescript', + decorators: true, + ...(filename.endsWith('.tsx') ? { tsx: true } : null), + } + throw new Error(`Unknown file type: ${filename}`) +} + +function createExportVisitor(VisitorCtor: typeof Visitor) { + class ExportVisitor extends VisitorCtor { + hasNonDefaultExportDeclaration = false + hasExportDefaultExpression = false + constructor() { + super() + type ExtractDeclName = T extends `visit${infer N}` ? N : never + const nonDefaultExportDecls: ExtractDeclName[] = [ + 'ExportDeclaration', // export const a = {} + 'ExportNamedDeclaration', // export {}, export * as a from './a' + 'ExportAllDeclaration', // export * from './a' + ] + + nonDefaultExportDecls.forEach((decl) => { + this[`visit${decl}`] = (n: any) => { + this.hasNonDefaultExportDeclaration = true + return n + } + }) + } + visitExportDefaultExpression( + n: ExportDefaultExpression + ): ModuleDeclaration { + this.hasExportDefaultExpression = true + return n + } + } + return new ExportVisitor() +} diff --git a/test/index.test.ts b/test/index.test.ts index 71c946a21..0a0b94a2a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -5,6 +5,7 @@ import fs from 'fs-extra' import glob from 'globby' import waitForExpect from 'wait-for-expect' import { fileURLToPath } from 'url' +import { runInNewContext } from 'vm' import { debouncePromise, slash } from '../src/utils' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -1715,3 +1716,80 @@ test('.d.ts files should be cleaned when --clean and --experimental-dts are prov expect(result3.outFiles).not.toContain('bar.d.ts') expect(result3.outFiles).not.toContain('bar.js') }) + +test('cjsInterop', async () => { + async function runCjsInteropTest( + name: string, + files: Record, + entry?: string + ) { + const { output } = await run(`${getTestName()}-${name}`, files, { + flags: [ + ['--format', 'cjs'], + '--cjsInterop', + ...(entry ? ['--entry.index', entry] : []), + ].flat(), + }) + const exp = {} + const mod = { exports: exp } + runInNewContext(output, { module: mod, exports: exp }) + return mod.exports + } + + await expect( + runCjsInteropTest('simple', { + 'input.ts': `export default { hello: 'world' }`, + }) + ).resolves.toEqual({ hello: 'world' }) + + await expect( + runCjsInteropTest('non-default', { + 'input.ts': `export const a = { hello: 'world' }`, + }) + ).resolves.toEqual(expect.objectContaining({ a: { hello: 'world' } })) + + await expect( + runCjsInteropTest('multiple-export', { + 'input.ts': ` + export const a = 1 + export default { hello: 'world' } + `, + }) + ).resolves.toEqual( + expect.objectContaining({ a: 1, default: { hello: 'world' } }) + ) + + await expect( + runCjsInteropTest('multiple-files', { + 'input.ts': ` + export * as a from './a' + export default { hello: 'world' } + `, + 'a.ts': 'export const a = 1', + }) + ).resolves.toEqual( + expect.objectContaining({ a: { a: 1 }, default: { hello: 'world' } }) + ) + + await expect( + runCjsInteropTest('no-export', { + 'input.ts': `console.log()`, + }) + ).resolves.toEqual({}) + + const tsAssertion = ` + const b = 1; + export const a = b; + ` + await expect( + runCjsInteropTest('file-extension-1', { 'input.ts': tsAssertion }) + ).resolves.toEqual(expect.objectContaining({ a: 1 })) + + await expect( + runCjsInteropTest( + 'file-extension-2', + { 'input.tsx': tsAssertion }, + 'input.tsx' + ) + ).rejects.toThrowError('Unexpected end of file before a closing "string" tag') +})