Skip to content

Commit 39efcf4

Browse files
committed
feat: add cjsInterop support without splitting flag
1 parent 00188a0 commit 39efcf4

File tree

4 files changed

+172
-7
lines changed

4 files changed

+172
-7
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"url": "https://github.com/egoist/tsup.git"
2121
},
2222
"scripts": {
23-
"dev": "npm run build-fast -- --watch",
23+
"dev": "npm run build-fast -- --sourcemap --watch",
2424
"build": "tsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting",
2525
"prepublishOnly": "npm run build",
2626
"test": "npm run build && npm run test-only",

src/plugin.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SourceMapConsumer, SourceMapGenerator, RawSourceMap } from 'source-map'
44
import { Format, NormalizedOptions } from '.'
55
import { outputFile } from './fs'
66
import { Logger } from './log'
7-
import { MaybePromise } from './utils'
7+
import { MaybePromise, slash } from './utils'
88
import { SourceMap } from 'rollup'
99

1010
export type ChunkInfo = {
@@ -124,7 +124,8 @@ export class PluginContainer {
124124
.filter((file) => !file.path.endsWith('.map'))
125125
.map((file): ChunkInfo | AssetInfo => {
126126
if (isJS(file.path) || isCSS(file.path)) {
127-
const relativePath = path.relative(process.cwd(), file.path)
127+
// esbuild is using "/" as a separator in Windows as well
128+
const relativePath = slash(path.relative(process.cwd(), file.path))
128129
const meta = metafile?.outputs[relativePath]
129130
return {
130131
type: 'chunk',

src/plugins/cjs-interop.ts

+90-4
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,112 @@
1+
import type {
2+
ExportDefaultExpression,
3+
ModuleDeclaration,
4+
ParseOptions,
5+
} from '@swc/core'
6+
import type { Visitor } from '@swc/core/Visitor'
7+
import fs from 'fs/promises'
8+
import path from 'path'
9+
import { PrettyError } from '../errors'
110
import { Plugin } from '../plugin'
11+
import { localRequire } from '../utils'
212

313
export const cjsInterop = (): Plugin => {
414
return {
515
name: 'cjs-interop',
616

717
async renderChunk(code, info) {
18+
const { entryPoint } = info
819
if (
920
!this.options.cjsInterop ||
1021
this.format !== 'cjs' ||
1122
info.type !== 'chunk' ||
1223
!/\.(js|cjs)$/.test(info.path) ||
13-
!info.entryPoint ||
14-
info.exports?.length !== 1 ||
15-
info.exports[0] !== 'default'
24+
!entryPoint
1625
) {
1726
return
1827
}
1928

29+
if (this.splitting) {
30+
// there is exports metadata when cjs+splitting is set
31+
if (info.exports?.length !== 1 || info.exports[0] !== 'default') return
32+
} else {
33+
const swc: typeof import('@swc/core') = localRequire('@swc/core')
34+
const { Visitor }: typeof import('@swc/core/Visitor') =
35+
localRequire('@swc/core/Visitor')
36+
if (!swc || !Visitor) {
37+
throw new PrettyError(
38+
`@swc/core is required for cjsInterop when splitting is not enabled. Please install it with \`npm install @swc/core -D\``
39+
)
40+
}
41+
42+
try {
43+
const entrySource = await fs.readFile(entryPoint, {
44+
encoding: 'utf8',
45+
})
46+
const ast = await swc.parse(entrySource, getParseOptions(entryPoint))
47+
const visitor = createExportVisitor(Visitor)
48+
visitor.visitProgram(ast)
49+
50+
if (
51+
!visitor.hasExportDefaultExpression ||
52+
visitor.hasNonDefaultExportDeclaration
53+
)
54+
return
55+
} catch {
56+
return
57+
}
58+
}
59+
2060
return {
21-
code: code + '\nmodule.exports = exports.default;\n',
61+
code: code + '\nmodule.exports=module.exports.default;\n',
2262
map: info.map,
2363
}
2464
},
2565
}
2666
}
67+
68+
function getParseOptions(filename: string): ParseOptions {
69+
if (/\.([cm]?js|jsx)$/.test(filename))
70+
return {
71+
syntax: 'ecmascript',
72+
decorators: true,
73+
...(filename.endsWith('.jsx') ? { jsx: true } : null),
74+
}
75+
if (/\.([cm]?ts|tsx)$/.test(filename))
76+
return {
77+
syntax: 'typescript',
78+
decorators: true,
79+
...(filename.endsWith('.tsx') ? { tsx: true } : null),
80+
}
81+
throw new Error(`Unknown file type: ${filename}`)
82+
}
83+
84+
function createExportVisitor(VisitorCtor: typeof Visitor) {
85+
class ExportVisitor extends VisitorCtor {
86+
hasNonDefaultExportDeclaration = false
87+
hasExportDefaultExpression = false
88+
constructor() {
89+
super()
90+
type ExtractDeclName<T> = T extends `visit${infer N}` ? N : never
91+
const nonDefaultExportDecls: ExtractDeclName<keyof Visitor>[] = [
92+
'ExportDeclaration', // export const a = {}
93+
'ExportNamedDeclaration', // export {}, export * as a from './a'
94+
'ExportAllDeclaration', // export * from './a'
95+
]
96+
97+
nonDefaultExportDecls.forEach((decl) => {
98+
this[`visit${decl}`] = (n: any) => {
99+
this.hasNonDefaultExportDeclaration = true
100+
return n
101+
}
102+
})
103+
}
104+
visitExportDefaultExpression(
105+
n: ExportDefaultExpression
106+
): ModuleDeclaration {
107+
this.hasExportDefaultExpression = true
108+
return n
109+
}
110+
}
111+
return new ExportVisitor()
112+
}

test/index.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import fs from 'fs-extra'
55
import glob from 'globby'
66
import waitForExpect from 'wait-for-expect'
77
import { fileURLToPath } from 'url'
8+
import { runInNewContext } from 'vm'
89
import { debouncePromise, slash } from '../src/utils'
910

1011
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
17151716
expect(result3.outFiles).not.toContain('bar.d.ts')
17161717
expect(result3.outFiles).not.toContain('bar.js')
17171718
})
1719+
1720+
test('cjsInterop', async () => {
1721+
async function runCjsInteropTest(
1722+
name: string,
1723+
files: Record<string, string>,
1724+
entry?: string
1725+
) {
1726+
const { output } = await run(`${getTestName()}-${name}`, files, {
1727+
flags: [
1728+
['--format', 'cjs'],
1729+
'--cjsInterop',
1730+
...(entry ? ['--entry.index', entry] : []),
1731+
].flat(),
1732+
})
1733+
const exp = {}
1734+
const mod = { exports: exp }
1735+
runInNewContext(output, { module: mod, exports: exp })
1736+
return mod.exports
1737+
}
1738+
1739+
await expect(
1740+
runCjsInteropTest('simple', {
1741+
'input.ts': `export default { hello: 'world' }`,
1742+
})
1743+
).resolves.toEqual({ hello: 'world' })
1744+
1745+
await expect(
1746+
runCjsInteropTest('non-default', {
1747+
'input.ts': `export const a = { hello: 'world' }`,
1748+
})
1749+
).resolves.toEqual(expect.objectContaining({ a: { hello: 'world' } }))
1750+
1751+
await expect(
1752+
runCjsInteropTest('multiple-export', {
1753+
'input.ts': `
1754+
export const a = 1
1755+
export default { hello: 'world' }
1756+
`,
1757+
})
1758+
).resolves.toEqual(
1759+
expect.objectContaining({ a: 1, default: { hello: 'world' } })
1760+
)
1761+
1762+
await expect(
1763+
runCjsInteropTest('multiple-files', {
1764+
'input.ts': `
1765+
export * as a from './a'
1766+
export default { hello: 'world' }
1767+
`,
1768+
'a.ts': 'export const a = 1',
1769+
})
1770+
).resolves.toEqual(
1771+
expect.objectContaining({ a: { a: 1 }, default: { hello: 'world' } })
1772+
)
1773+
1774+
await expect(
1775+
runCjsInteropTest('no-export', {
1776+
'input.ts': `console.log()`,
1777+
})
1778+
).resolves.toEqual({})
1779+
1780+
const tsAssertion = `
1781+
const b = 1;
1782+
export const a = <string>b;
1783+
`
1784+
await expect(
1785+
runCjsInteropTest('file-extension-1', { 'input.ts': tsAssertion })
1786+
).resolves.toEqual(expect.objectContaining({ a: 1 }))
1787+
1788+
await expect(
1789+
runCjsInteropTest(
1790+
'file-extension-2',
1791+
{ 'input.tsx': tsAssertion },
1792+
'input.tsx'
1793+
)
1794+
).rejects.toThrowError('Unexpected end of file before a closing "string" tag')
1795+
})

0 commit comments

Comments
 (0)