Skip to content

Commit

Permalink
feat: add cjsInterop support without splitting flag
Browse files Browse the repository at this point in the history
  • Loading branch information
tmkx committed Dec 8, 2023
1 parent 8c26e63 commit 848108a
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 6 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ export class PluginContainer {
.map((file): ChunkInfo | AssetInfo => {
if (isJS(file.path) || isCSS(file.path)) {
const relativePath = path.relative(process.cwd(), file.path)
const meta = metafile?.outputs[relativePath]
const meta =
metafile?.outputs[relativePath] ||
// esbuild is using "/" as a separator in Windows as well
metafile?.outputs[relativePath.replaceAll(path.sep, path.posix.sep)]
return {
type: 'chunk',
path: file.path,
Expand Down
77 changes: 73 additions & 4 deletions src/plugins/cjs-interop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ExportDefaultExpression, ModuleDeclaration } from '@swc/core'
import type { Visitor } from '@swc/core/Visitor'
import fs from 'fs/promises'
import { PrettyError } from '../errors'
import { Plugin } from '../plugin'
import { localRequire } from '../utils'

export const cjsInterop = (): Plugin => {
return {
Expand All @@ -10,17 +15,81 @@ export const cjsInterop = (): Plugin => {
this.format !== 'cjs' ||
info.type !== 'chunk' ||
!/\.(js|cjs)$/.test(info.path) ||
!info.entryPoint ||
info.exports?.length !== 1 ||
info.exports[0] !== 'default'
!info.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\``
)
}

let entrySource: string | undefined
try {
entrySource = await fs.readFile(info.entryPoint!, {
encoding: 'utf8',
})
} catch {}
if (!entrySource) return

const ast = await swc.parse(entrySource, {
syntax: 'typescript',
decorators: true,
tsx: true,
})
const visitor = createExportVisitor(Visitor)
visitor.visitProgram(ast)

if (
!visitor.hasExportDefaultExpression ||
visitor.hasNonDefaultExportDeclaration
)
return
}

return {
code: code + '\nmodule.exports = exports.default;\n',
code: code + '\nmodule.exports=module.exports.default;\n',
map: info.map,
}
},
}
}

function createExportVisitor(VisitorCtor: typeof Visitor) {
class ExportVisitor extends VisitorCtor {
hasNonDefaultExportDeclaration = false
hasExportDefaultExpression = false
constructor() {
super()
type ExtractDeclName<T> = T extends `visit${infer N}` ? N : never
const nonDefaultExportDecls: ExtractDeclName<keyof Visitor>[] = [
'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()
}
57 changes: 57 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -1715,3 +1716,59 @@ 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<string, string>
) {
const { output } = await run(`${getTestName()}-${name}`, files, {
flags: ['--format', 'cjs', '--cjsInterop'],
})
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({})
})

0 comments on commit 848108a

Please sign in to comment.