From fb4c2b6e75e29c58956eaaa6afab12b130accb14 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Sun, 25 Jun 2023 02:58:14 -0600 Subject: [PATCH] feat: Ensure matching declaration file exists for each output bundle format (#934) * Ensure dts files match all output formats * Update docs --- docs/README.md | 2 +- src/esbuild/index.ts | 25 +------------------------ src/options.ts | 2 +- src/rollup.ts | 36 ++++++++++++++++++++++-------------- src/utils.ts | 28 ++++++++++++++++++++++++++++ test/index.test.ts | 35 ++++++++++++++++++++++++++++++++--- 6 files changed, 85 insertions(+), 43 deletions(-) diff --git a/docs/README.md b/docs/README.md index 8c88d291..c6fd6f4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -191,7 +191,7 @@ export default defineConfig({ tsup index.ts --dts ``` -This will emit `./dist/index.js` and `./dist/index.d.ts`. +This will emit `./dist/index.js` and `./dist/index.d.ts`. When emitting multiple [bundle formats](#bundle-formats), one declaration file per bundle format is generated. This is required for consumers to get accurate type checking with TypeScript. Note that declaration files generated by any tool other than `tsc` are not guaranteed to be error-free, so it's a good idea to test the output with `tsc` or a tool like [@arethetypeswrong/cli](https://www.npmjs.com/package/@arethetypeswrong/cli) before publishing. If you have multiple entry files, each entry will get a corresponding `.d.ts` file. So when you only want to generate declaration file for a single entry, use `--dts ` format, e.g. `--dts src/index.ts`. diff --git a/src/esbuild/index.ts b/src/esbuild/index.ts index a048d620..aec991d0 100644 --- a/src/esbuild/index.ts +++ b/src/esbuild/index.ts @@ -14,35 +14,12 @@ import { externalPlugin } from './external' import { postcssPlugin } from './postcss' import { sveltePlugin } from './svelte' import consola from 'consola' -import { truthy } from '../utils' +import { defaultOutExtension, truthy } from '../utils' import { swcPlugin } from './swc' import { nativeNodeModulesPlugin } from './native-node-module' import { PluginContainer } from '../plugin' import { OutExtensionFactory } from '../options' -const defaultOutExtension = ({ - format, - pkgType, -}: { - format: Format - pkgType?: string -}): { js: string } => { - let jsExtension = '.js' - const isModule = pkgType === 'module' - if (isModule && format === 'cjs') { - jsExtension = '.cjs' - } - if (!isModule && format === 'esm') { - jsExtension = '.mjs' - } - if (format === 'iife') { - jsExtension = '.global.js' - } - return { - js: jsExtension, - } -} - const getOutputExtensionMap = ( options: NormalizedOptions, format: Format, diff --git a/src/options.ts b/src/options.ts index c1540875..07fd22f5 100644 --- a/src/options.ts +++ b/src/options.ts @@ -14,7 +14,7 @@ export type ContextForOutPathGeneration = { pkgType?: string } -export type OutExtensionObject = { js?: string } +export type OutExtensionObject = { js?: string, dts?: string } export type OutExtensionFactory = ( ctx: ContextForOutPathGeneration diff --git a/src/rollup.ts b/src/rollup.ts index d7e68504..227fc311 100644 --- a/src/rollup.ts +++ b/src/rollup.ts @@ -5,10 +5,10 @@ import ts from 'typescript' import hashbangPlugin from 'rollup-plugin-hashbang' import jsonPlugin from '@rollup/plugin-json' import { handleError } from './errors' -import { removeFiles } from './utils' +import { defaultOutExtension, removeFiles } from './utils' import { TsResolveOptions, tsResolvePlugin } from './rollup/ts-resolve' import { createLogger, setSilent } from './log' -import { getProductionDeps } from './load' +import { getProductionDeps, loadPkg } from './load' import path from 'path' import { reportSize } from './lib/report-size' import resolveFrom from 'resolve-from' @@ -32,7 +32,7 @@ const dtsPlugin: typeof import('rollup-plugin-dts') = require('rollup-plugin-dts type RollupConfig = { inputConfig: InputOptions - outputConfig: OutputOptions + outputConfig: OutputOptions[] } const findLowestCommonAncestor = (filepaths: string[]) => { @@ -111,6 +111,7 @@ const getRollupConfig = async ( } } + const pkg = await loadPkg(process.cwd()) const deps = await getProductionDeps(process.cwd()) const tsupCleanPlugin: Plugin = { @@ -188,13 +189,19 @@ const getRollupConfig = async ( ...(options.external || []), ], }, - outputConfig: { - dir: options.outDir || 'dist', - format: 'esm', - exports: 'named', - banner: dtsOptions.banner, - footer: dtsOptions.footer, - }, + outputConfig: options.format.map((format) => { + const outputExtension = + options.outExtension?.({ format, options, pkgType: pkg.type }).dts || + defaultOutExtension({ format, pkgType: pkg.type }).dts + return { + dir: options.outDir || 'dist', + format: 'esm', + exports: 'named', + banner: dtsOptions.banner, + footer: dtsOptions.footer, + entryFileNames: `[name]${outputExtension}`, + } + }), } } @@ -207,15 +214,16 @@ async function runRollup(options: RollupConfig) { } logger.info('dts', 'Build start') const bundle = await rollup(options.inputConfig) - const result = await bundle.write(options.outputConfig) + const results = await Promise.all(options.outputConfig.map(bundle.write)) + const outputs = results.flatMap((result) => result.output); logger.success('dts', `⚡️ Build success in ${getDuration()}`) reportSize( logger, 'dts', - result.output.reduce((res, info) => { + outputs.reduce((res, info) => { const name = path.relative( process.cwd(), - path.join(options.outputConfig.dir || '.', info.fileName) + path.join(options.outputConfig[0].dir || '.', info.fileName) ) return { ...res, @@ -231,7 +239,7 @@ async function runRollup(options: RollupConfig) { async function watchRollup(options: { inputConfig: InputOptions - outputConfig: OutputOptions + outputConfig: OutputOptions[] }) { const { watch } = await import('rollup') diff --git a/src/utils.ts b/src/utils.ts index 9b9ec271..ad1202b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import fs from 'fs' import glob from 'globby' import resolveFrom from 'resolve-from' import strip from 'strip-json-comments' +import { Format } from './options' export type MaybePromise = T | Promise @@ -129,3 +130,30 @@ export function jsoncParse(data: string) { return {} } } + +export function defaultOutExtension({ + format, + pkgType, +}: { + format: Format + pkgType?: string +}): { js: string, dts: string } { + let jsExtension = '.js' + let dtsExtension = '.d.ts' + const isModule = pkgType === 'module' + if (isModule && format === 'cjs') { + jsExtension = '.cjs' + dtsExtension = '.d.cts' + } + if (!isModule && format === 'esm') { + jsExtension = '.mjs' + dtsExtension = '.d.mts' + } + if (format === 'iife') { + jsExtension = '.global.js' + } + return { + js: jsExtension, + dts: dtsExtension, + } +} diff --git a/test/index.test.ts b/test/index.test.ts index 721c3f65..df2c19d7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1248,7 +1248,7 @@ test(`custom tsconfig should pass to dts plugin`, async () => { } `, }) - expect(outFiles).toEqual(['input.d.ts']) + expect(outFiles).toEqual(['input.d.mts']) }) test(`should generate export {} when there are no exports in source file`, async () => { @@ -1268,8 +1268,8 @@ test(`should generate export {} when there are no exports in source file`, async } `, }) - expect(outFiles).toEqual(['input.d.ts', 'input.mjs']) - expect(await getFileContent('dist/input.d.ts')).toContain('export { }') + expect(outFiles).toEqual(['input.d.mts', 'input.mjs']) + expect(await getFileContent('dist/input.d.mts')).toContain('export { }') }) test('custom inject style function', async () => { @@ -1335,3 +1335,32 @@ test('should load postcss esm config', async () => { expect(outFiles).toEqual(['input.cjs', 'input.css']) expect(await getFileContent('dist/input.css')).toContain('color: blue;') }) + +test('should emit a declaration file per format', async () => { + const { outFiles } = await run(getTestName(), { + 'input.ts': `export default 'foo'`, + 'tsup.config.ts': ` + export default { + entry: ['src/input.ts'], + format: ['esm', 'cjs'], + dts: true + }`, + }); + expect(outFiles).toEqual(['input.d.mts', 'input.d.ts', 'input.js', 'input.mjs']) +}); + +test('should emit a declaration file per format (type: module)', async () => { + const { outFiles } = await run(getTestName(), { + 'input.ts': `export default 'foo'`, + 'package.json': `{ + "type": "module" + }`, + 'tsup.config.ts': ` + export default { + entry: ['src/input.ts'], + format: ['esm', 'cjs'], + dts: true + }`, + }); + expect(outFiles).toEqual(['input.cjs', 'input.d.cts', 'input.d.ts', 'input.js']) +});