diff --git a/src/api-extractor.ts b/src/api-extractor.ts index 2d66a5031..4cae04c32 100644 --- a/src/api-extractor.ts +++ b/src/api-extractor.ts @@ -91,7 +91,9 @@ async function rollupDtsFiles( const declarationDir = ensureTempDeclarationDir() const outDir = options.outDir || 'dist' const pkg = await loadPkg(process.cwd()) - const dtsExtension = defaultOutExtension({ format, pkgType: pkg.type }).dts + const dtsExtension = + options.outputExtensionMap.get(format)?.dts || + defaultOutExtension({ format, pkgType: pkg.type }).dts let dtsInputFilePath = path.join( declarationDir, diff --git a/src/index.ts b/src/index.ts index c0394514d..4d3bf8d1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { type MaybePromise, debouncePromise, removeFiles, + resolveOutputExtensionMap, slash, toObjectEntry, } from './utils' @@ -76,14 +77,17 @@ const normalizeOptions = async ( ...optionsOverride, } + const formats = + typeof _options.format === 'string' + ? [_options.format] + : _options.format || ['cjs'] + const options: Partial = { outDir: 'dist', removeNodeProtocol: true, ..._options, - format: - typeof _options.format === 'string' - ? [_options.format as Format] - : _options.format || ['cjs'], + format: formats, + dts: typeof _options.dts === 'boolean' ? _options.dts @@ -173,6 +177,10 @@ const normalizeOptions = async ( options.target = 'node16' } + options.outputExtensionMap = await resolveOutputExtensionMap( + options as NormalizedOptions, + ) + return options as NormalizedOptions } diff --git a/src/options.ts b/src/options.ts index 827790d43..e0f669b3d 100644 --- a/src/options.ts +++ b/src/options.ts @@ -272,4 +272,11 @@ export type NormalizedOptions = Omit< tsconfigResolvePaths: Record tsconfigDecoratorMetadata?: boolean format: Format[] + /** + * Custom file extension per each + * {@linkcode Format | module format}. + * + * @since 8.4.0 + */ + outputExtensionMap: Map } diff --git a/src/rollup.ts b/src/rollup.ts index 37fe23583..cc080b1ea 100644 --- a/src/rollup.ts +++ b/src/rollup.ts @@ -156,7 +156,7 @@ const getRollupConfig = async ( }, outputConfig: options.format.map((format): OutputOptions => { const outputExtension = - options.outExtension?.({ format, options, pkgType: pkg.type }).dts || + options.outputExtensionMap.get(format)?.dts || defaultOutExtension({ format, pkgType: pkg.type }).dts return { dir: options.outDir || 'dist', diff --git a/src/utils.ts b/src/utils.ts index 3e6294a2a..7ec298d2e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,8 @@ import path from 'node:path' import resolveFrom from 'resolve-from' import strip from 'strip-json-comments' import { glob } from 'tinyglobby' -import type { Entry, Format } from './options' +import { loadPkg } from './load' +import type { Entry, Format, NormalizedOptions } from './options' export type MaybePromise = T | Promise @@ -242,3 +243,40 @@ export function writeFileSync(filePath: string, content: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }) fs.writeFileSync(filePath, content) } + +/** + * Resolves the + * {@linkcode NormalizedOptions.outputExtensionMap | output extension map} + * for each specified {@linkcode Format | format} + * in the provided {@linkcode options}. + * + * @param options - The normalized options containing format and output extension details. + * @returns A {@linkcode Promise | promise} that resolves to a {@linkcode Map}, where each key is a {@linkcode Format | format} and each value is an object containing the resolved output extensions for both `js` and `dts` files. + * + * @internal + */ +export const resolveOutputExtensionMap = async ( + options: NormalizedOptions, +): Promise => { + const pkg = await loadPkg(process.cwd()) + + const formatOutExtension = new Map( + options.format.map((format) => { + const outputExtensions = options.outExtension?.({ + format, + options, + pkgType: pkg.type, + }) + + return [ + format, + { + ...defaultOutExtension({ format, pkgType: pkg.type }), + ...(outputExtensions || {}), + }, + ] as const + }), + ) + + return formatOutExtension +} diff --git a/test/dts.test.ts b/test/dts.test.ts index 174a44388..4223bb0b9 100644 --- a/test/dts.test.ts +++ b/test/dts.test.ts @@ -258,29 +258,29 @@ test('should emit declaration files with experimentalDts', async () => { export function sharedFunction(value: T): T | null { return value || null } - + type sharedType = { shared: boolean } - + export type { sharedType } `, 'src/server.ts': ` export * from './shared' /** - * Comment for server render function + * Comment for server render function */ export function render(options: ServerRenderOptions): string { return JSON.stringify(options) } - + export interface ServerRenderOptions { /** * Comment for ServerRenderOptions.stream - * + * * @public - * + * * @my_custom_tag */ stream: boolean @@ -298,7 +298,7 @@ test('should emit declaration files with experimentalDts', async () => { import * as ServerThirdPartyNamespace from 'react-dom'; export { ServerThirdPartyNamespace } - // Export a third party module + // Export a third party module export * from 'react-dom/server'; `, @@ -308,7 +308,7 @@ test('should emit declaration files with experimentalDts', async () => { export function render(options: ClientRenderOptions): string { return JSON.stringify(options) } - + export interface ClientRenderOptions { document: boolean } @@ -473,3 +473,58 @@ test('declaration files with multiple entrypoints #316', async () => { 'dist/bar/index.d.ts', ).toMatchSnapshot() }) + +test('custom dts output extension', async ({ expect, task }) => { + const { outFiles } = await run( + getTestName(), + { + 'src/types.ts': `export type Person = { name: string }`, + 'src/index.ts': `export const foo = [1, 2, 3]\nexport type { Person } from './types'`, + 'tsup.config.ts': `export default { + name: '${task.name}', + entry: { index: 'src/index.ts' }, + dts: true, + format: ['esm', 'cjs'], + outExtension({ format }) { + return { + js: format === 'esm' ? '.cjs' : '.mjs', + dts: format === 'esm' ? '.d.cts' : '.d.mts', + } + }, + }`, + 'package.json': JSON.stringify( + { + name: 'custom-dts-output-extension', + description: task.name, + type: 'module', + }, + null, + 2, + ), + 'tsconfig.json': JSON.stringify( + { + compilerOptions: { + outDir: './dist', + rootDir: './src', + moduleResolution: 'Bundler', + module: 'ESNext', + strict: true, + skipLibCheck: true, + }, + include: ['src'], + }, + null, + 2, + ), + }, + { + entry: [], + }, + ) + expect(outFiles).toStrictEqual([ + 'index.cjs', + 'index.d.cts', + 'index.d.mts', + 'index.mjs', + ]) +}) diff --git a/test/experimental-dts.test.ts b/test/experimental-dts.test.ts new file mode 100644 index 000000000..063439c87 --- /dev/null +++ b/test/experimental-dts.test.ts @@ -0,0 +1,63 @@ +import { test } from 'vitest' +import { getTestName, run } from './utils.js' + +test('custom outExtension works with experimentalDts', async ({ + expect, + task, +}) => { + const { outFiles } = await run( + getTestName(), + { + 'src/types.ts': `export type Person = { name: string }`, + 'src/index.ts': `export const foo = [1, 2, 3]\nexport type { Person } from './types'`, + 'tsup.config.ts': `export default { + name: '${task.name}', + entry: { index: 'src/index.ts' }, + format: ['esm', 'cjs'], + experimentalDts: true, + outExtension({ format }) { + return { + js: format === 'cjs' ? '.cjs' : '.mjs', + dts: format === 'cjs' ? '.d.cts' : '.d.mts', + } + }, + }`, + 'package.json': JSON.stringify( + { + name: 'custom-dts-output-extension-with-experimental-dts', + description: task.name, + type: 'module', + }, + null, + 2, + ), + 'tsconfig.json': JSON.stringify( + { + compilerOptions: { + outDir: './dist', + rootDir: './src', + moduleResolution: 'Bundler', + module: 'ESNext', + strict: true, + skipLibCheck: true, + }, + include: ['src'], + }, + null, + 2, + ), + }, + { + entry: [], + }, + ) + + expect(outFiles).toStrictEqual([ + '_tsup-dts-rollup.d.cts', + '_tsup-dts-rollup.d.mts', + 'index.cjs', + 'index.d.cts', + 'index.d.mts', + 'index.mjs', + ]) +}) diff --git a/test/index.test.ts b/test/index.test.ts index 5b42572e2..3da659eb6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -217,7 +217,7 @@ test('onSuccess: use a function from config file', async () => { await new Promise((resolve) => { setTimeout(() => { console.log('world') - resolve('') + resolve('') }, 1_000) }) } @@ -601,7 +601,7 @@ test('use rollup for treeshaking --format cjs', async () => { }`, 'input.tsx': ` import ReactSelect from 'react-select' - + export const Component = (props: {}) => { return };