diff --git a/src/index.ts b/src/index.ts index 64a1d690..497a6bb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { terserPlugin } from './plugins/terser' import { runTypeScriptCompiler } from './tsc' import { runDtsRollup } from './api-extractor' import { cjsInterop } from './plugins/cjs-interop' +import { rewriteImportExtensions } from './plugins/rewrite-import-extensions' import type { Format, KILL_SIGNAL, NormalizedOptions, Options } from './options' export type { Format, Options, NormalizedOptions } @@ -313,6 +314,7 @@ export async function build(_options: Options) { ...options.format.map(async (format, index) => { const pluginContainer = new PluginContainer([ shebang(), + rewriteImportExtensions(), ...(options.plugins || []), treeShakingPlugin({ treeshake: options.treeshake, diff --git a/src/plugins/rewrite-dts-import-extensions.ts b/src/plugins/rewrite-dts-import-extensions.ts new file mode 100644 index 00000000..fcb5a897 --- /dev/null +++ b/src/plugins/rewrite-dts-import-extensions.ts @@ -0,0 +1,32 @@ +import type { Plugin } from 'rollup' + +const RELATIVE_TS_IMPORT_PATTERN = + /(?<=(?:from\s+|import\s*\(|require\s*\()['"])(\.\.?\/[^'"]*)(\.(?:ts|tsx|mts|cts))(\?[^'"]*)?(?=['"])/g + +const getOutputExtension = (tsExt: string, outputPath: string): string => { + if (tsExt === '.mts') return '.mjs' + if (tsExt === '.cts') return '.cjs' + if (outputPath.endsWith('.mjs') || outputPath.endsWith('.d.mts')) + return '.mjs' + if (outputPath.endsWith('.cjs') || outputPath.endsWith('.d.cts')) + return '.cjs' + return '.js' +} + +export const rewriteDtsImportExtensionsPlugin = (): Plugin => ({ + name: 'tsup:rewrite-dts-import-extensions', + renderChunk(code, chunk) { + let touched = false + const rewritten = code.replace( + RELATIVE_TS_IMPORT_PATTERN, + (_, pathWithoutExt, tsExt, query = '') => { + touched = true + return ( + pathWithoutExt + getOutputExtension(tsExt, chunk.fileName) + query + ) + }, + ) + if (!touched) return null + return { code: rewritten, map: null } + }, +}) diff --git a/src/plugins/rewrite-import-extensions.ts b/src/plugins/rewrite-import-extensions.ts new file mode 100644 index 00000000..3d77078d --- /dev/null +++ b/src/plugins/rewrite-import-extensions.ts @@ -0,0 +1,36 @@ +import type { Plugin } from '../plugin' + +const getOutputExtension = (tsExt: string, outputPath: string): string => { + if (tsExt === '.mts') return '.mjs' + if (tsExt === '.cts') return '.cjs' + if (outputPath.endsWith('.mjs')) return '.mjs' + if (outputPath.endsWith('.cjs')) return '.cjs' + return '.js' +} + +const RELATIVE_IMPORT_PATTERN = + /(?<=(?:from\s+|import\s*\(|require\s*\()['"])(\.\.?\/[^'"]*)(\.(?:ts|tsx|mts|cts))(\?[^'"]*)?(?=['"])/g + +export const rewriteImportExtensions = (): Plugin => { + return { + name: 'rewrite-import-extensions', + + renderChunk(code, info) { + if (!/\.(js|mjs|cjs)$/.test(info.path)) return + + let touched = false + + const rewritten = code.replace( + RELATIVE_IMPORT_PATTERN, + (_, pathWithoutExt, tsExt, query = '') => { + touched = true + return pathWithoutExt + getOutputExtension(tsExt, info.path) + query + }, + ) + + if (!touched) return + + return { code: rewritten } + }, + } +} diff --git a/src/rollup.ts b/src/rollup.ts index 90aef3be..b78889e6 100644 --- a/src/rollup.ts +++ b/src/rollup.ts @@ -12,6 +12,7 @@ import { reportSize } from './lib/report-size' import type { NormalizedOptions } from './' import type { InputOptions, OutputOptions, Plugin } from 'rollup' import { FixDtsDefaultCjsExportsPlugin } from 'fix-dts-default-cjs-exports/rollup' +import { rewriteDtsImportExtensionsPlugin } from './plugins/rewrite-dts-import-extensions' const logger = createLogger() @@ -149,6 +150,7 @@ const getRollupConfig = async ( entryFileNames: `[name]${outputExtension}`, chunkFileNames: `[name]-[hash]${outputExtension}`, plugins: [ + rewriteDtsImportExtensionsPlugin(), format === 'cjs' && options.cjsInterop && FixDtsDefaultCjsExportsPlugin(), diff --git a/test/dts.test.ts b/test/dts.test.ts index 941db49d..ccc59515 100644 --- a/test/dts.test.ts +++ b/test/dts.test.ts @@ -480,3 +480,74 @@ test('declaration files with multiple entrypoints #316', async () => { 'dist/bar/index.d.ts', ).toMatchSnapshot() }) + +test('dts should rewrite .ts import extensions to .mjs for esm format', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'src/input.ts': `export type { Foo } from './types.ts'`, + 'src/types.ts': `export type Foo = string`, + 'tsup.config.ts': ` + export default { + entry: ['src/input.ts'], + format: ['esm'], + dts: { only: true }, + external: [/\\.\\/types/] + } + `, + }, + { entry: [] }, + ) + expect(outFiles).toContain('input.d.mts') + const dts = await getFileContent('dist/input.d.mts') + expect(dts).toContain('./types.mjs') + expect(dts).not.toContain('./types.ts') +}) + +test('dts should rewrite .ts import extensions to .cjs for cjs format with type:module', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'src/input.ts': `export type { Foo } from './types.ts'`, + 'src/types.ts': `export type Foo = string`, + 'package.json': `{ "type": "module" }`, + 'tsup.config.ts': ` + export default { + entry: ['src/input.ts'], + format: ['cjs'], + dts: { only: true }, + external: [/\\.\\/types/] + } + `, + }, + { entry: [] }, + ) + expect(outFiles).toContain('input.d.cts') + const dts = await getFileContent('dist/input.d.cts') + expect(dts).toContain('./types.cjs') + expect(dts).not.toContain('./types.ts') +}) + +test('dts should rewrite .ts import extensions to .js for esm format with type:module', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'src/input.ts': `export type { Foo } from './types.ts'`, + 'src/types.ts': `export type Foo = string`, + 'package.json': `{ "type": "module" }`, + 'tsup.config.ts': ` + export default { + entry: ['src/input.ts'], + format: ['esm'], + dts: { only: true }, + external: [/\\.\\/types/] + } + `, + }, + { entry: [] }, + ) + expect(outFiles).toContain('input.d.ts') + const dts = await getFileContent('dist/input.d.ts') + expect(dts).toContain('./types.js') + expect(dts).not.toContain('./types.ts') +}) diff --git a/test/index.test.ts b/test/index.test.ts index 5b42572e..d6a9b7b1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -925,3 +925,136 @@ test('generate sourcemap with --treeshake', async () => { }), ) }) + +test('rewrite .ts import extensions with bundle:false', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `import { foo } from './foo.ts';\nexport { foo };`, + 'foo.ts': `export const foo = 'foo'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.ts'], + }, + ) + expect(outFiles).toContain('input.js') + expect(outFiles).toContain('foo.js') + const output = await getFileContent('dist/input.js') + expect(output).toContain('./foo.js') + expect(output).not.toContain('./foo.ts') +}) + +test('rewrite .ts import extensions to .mjs for esm format', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `import { foo } from './foo.ts';\nexport { foo };`, + 'foo.ts': `export const foo = 'foo'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.ts'], + flags: ['--format', 'esm'], + }, + ) + expect(outFiles).toContain('input.mjs') + const output = await getFileContent('dist/input.mjs') + expect(output).toContain('./foo.mjs') + expect(output).not.toContain('./foo.ts') +}) + +test('rewrite .ts import extensions to .cjs for cjs format with type:module', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `import { foo } from './foo.ts';\nexport { foo };`, + 'foo.ts': `export const foo = 'foo'`, + 'package.json': JSON.stringify({ type: 'module' }), + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.ts'], + flags: ['--format', 'cjs'], + }, + ) + expect(outFiles).toContain('input.cjs') + const output = await getFileContent('dist/input.cjs') + expect(output).toContain('./foo.cjs') + expect(output).not.toContain('./foo.ts') +}) + +test('rewrite .mts and .cts import extensions', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `import { foo } from './foo.mts';\nimport { bar } from './bar.cts';\nexport { foo, bar };`, + 'foo.mts': `export const foo = 'foo'`, + 'bar.cts': `export const bar = 'bar'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.mts', 'bar.cts'], + }, + ) + expect(outFiles).toContain('input.js') + const output = await getFileContent('dist/input.js') + expect(output).toContain('./foo.mjs') + expect(output).toContain('./bar.cjs') + expect(output).not.toContain('.mts') + expect(output).not.toContain('.cts') +}) + +test('rewrite export from with .ts extension', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `export { foo } from './foo.ts';`, + 'foo.ts': `export const foo = 'foo'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.ts'], + }, + ) + expect(outFiles).toContain('input.js') + const output = await getFileContent('dist/input.js') + expect(output).toContain('./foo.js') + expect(output).not.toContain('./foo.ts') +}) + +test('rewrite dynamic import with .ts extension', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `export const load = () => import('./foo.ts');`, + 'foo.ts': `export const foo = 'foo'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'foo.ts'], + }, + ) + expect(outFiles).toContain('input.js') + const output = await getFileContent('dist/input.js') + expect(output).toContain('./foo.js') + expect(output).not.toContain('./foo.ts') +}) + +test('rewrite .ts import extensions with query string', async () => { + const { getFileContent, outFiles } = await run( + getTestName(), + { + 'input.ts': `import data from './data.ts?raw';\nexport { data };`, + 'data.ts': `export default 'data'`, + 'tsup.config.ts': `export default { bundle: false }`, + }, + { + entry: ['input.ts', 'data.ts'], + }, + ) + expect(outFiles).toContain('input.js') + const output = await getFileContent('dist/input.js') + expect(output).toContain('./data.js?raw') + expect(output).not.toContain('./data.ts') +})