Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cjsInterop support without splitting flag #1056

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 3 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
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
96 changes: 92 additions & 4 deletions src/plugins/cjs-interop.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,114 @@
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) ||
sxzz marked this conversation as resolved.
Show resolved Hide resolved
!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 parseOptions = getParseOptions(entryPoint)
if (!parseOptions) return
const ast = await swc.parse(entrySource, parseOptions)
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',
sxzz marked this conversation as resolved.
Show resolved Hide resolved
map: info.map,
}
},
}
}

function getParseOptions(filename: string): ParseOptions | null {
if (/\.([cm]?js|jsx)$/.test(filename))
return {
syntax: 'ecmascript',
decorators: true,
jsx: filename.endsWith('.jsx'),
}
if (/\.([cm]?ts|tsx)$/.test(filename))
return {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge the object with previous one.

Copy link
Contributor Author

@tmkx tmkx Apr 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how to merge the objects? it's a union type:

type ParserConfig = TsParserConfig | EsParserConfig

syntax: 'typescript',
decorators: true,
tsx: filename.endsWith('.tsx'),
}
return null
}

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
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,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')
})
Loading