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 Apr 29, 2024
1 parent 00188a0 commit 9e18afb
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 7 deletions.
2 changes: 1 addition & 1 deletion package.json
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: 3 additions & 2 deletions src/plugin.ts
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down
107 changes: 103 additions & 4 deletions src/plugins/cjs-interop.ts
@@ -1,26 +1,125 @@
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 {
switch (path.extname(filename).toLowerCase()) {
case '.js':
return {
syntax: 'ecmascript',
decorators: true,
}
case '.jsx':
return {
syntax: 'ecmascript',
decorators: true,
jsx: true,
}
case '.ts':
return {
syntax: 'typescript',
decorators: true,
}
case '.tsx':
return {
syntax: 'typescript',
decorators: true,
tsx: true,
}
default:
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> = 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()
}
78 changes: 78 additions & 0 deletions test/index.test.ts
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,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<string, string>,
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 = <string>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')
})

0 comments on commit 9e18afb

Please sign in to comment.