Skip to content
Merged
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
1 change: 1 addition & 0 deletions dts.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"DepsConfig": "interface DepsConfig {\n neverBundle?: ExternalOption\n alwaysBundle?: Arrayable<string | RegExp> | NoExternalFn\n onlyBundle?: Arrayable<string | RegExp> | false\n onlyAllowBundle?: Arrayable<string | RegExp> | false\n skipNodeModulesBundle?: boolean\n}",
"DepsPlugin": "declare function DepsPlugin(_: ResolvedConfig, _: TsdownBundle): Plugin",
"DevtoolsOptions": "interface DevtoolsOptions extends NonNullable<InputOptions['devtools']> {\n ui?: boolean | Partial<StartOptions>\n clean?: boolean\n}",
"DtsOptions": "interface DtsOptions extends Options$1 {\n cjsReexport?: boolean\n}",
"ExeOptions": "interface ExeOptions extends ExeExtensionOptions {\n seaConfig?: Omit<SeaConfig, 'main' | 'output' | 'mainFormat'>\n fileName?: string | ((_: RolldownChunk) => string)\n outDir?: string\n}",
"ExportsOptions": "interface ExportsOptions {\n devExports?: boolean | string\n packageJson?: boolean\n all?: boolean\n exclude?: (RegExp | string)[]\n legacy?: boolean\n customExports?: Record<string, any> | ((_: Record<string, any>, _: { pkg: PackageJson; chunks: ChunksByFormat; isPublish: boolean }) => Awaitable<Record<string, any>>)\n inlinedDependencies?: boolean\n bin?: boolean | string | Record<string, string>\n}",
"Format": "type Format = ModuleFormat",
Expand Down
5 changes: 3 additions & 2 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,14 @@ async function buildSingle(
}

const configs: BuildOptions[] = [buildOptions]
if (format === 'cjs' && dts) {
if (format === 'cjs' && dts && (!isDualFormat || !dts.cjsReexport)) {
configs.push(
await getBuildOptions(
config,
format,
configFiles,
bundle,
true,
true, // cjsDts
isDualFormat,
),
)
Expand All @@ -308,6 +308,7 @@ async function buildSingle(
async function postBuild() {
await copy(config)
await buildExe(config, chunks)

if (!hasBuilt) {
await done(bundle)
}
Expand Down
21 changes: 19 additions & 2 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,27 @@ import type {
OutputOptions,
TreeshakingOptions,
} from 'rolldown'
import type { Options as DtsOptions } from 'rolldown-plugin-dts'
import type { Options as RolldownPluginDtsOptions } from 'rolldown-plugin-dts'
import type { Options as UnusedOptions } from 'unplugin-unused'

export interface DtsOptions extends RolldownPluginDtsOptions {
/**
* When building dual ESM+CJS formats, generate a `.d.cts` re-export stub
* instead of running a full second TypeScript compilation pass.
*
* The stub re-exports everything from the corresponding `.d.mts` file,
* ensuring CJS and ESM consumers share the same type declarations. This
* eliminates the TypeScript "dual module hazard" where separate `.d.cts`
* and `.d.mts` declarations cause `TS2352` ("neither type sufficiently
* overlaps") errors when casting between types derived from the same class.
*
* Only applies when building both `esm` and `cjs` formats simultaneously.
*
* @default false
*/
cjsReexport?: boolean
}

export type Sourcemap = boolean | 'inline' | 'hidden'
export type Format = ModuleFormat
export type NormalizedFormat = InternalModuleFormat
Expand Down Expand Up @@ -83,7 +101,6 @@ export type {
CopyOptionsFn,
DepsConfig,
DevtoolsOptions,
DtsOptions,
ExeOptions,
ExportsOptions,
NoExternalFn,
Expand Down
31 changes: 30 additions & 1 deletion src/features/rolldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type Plugin,
type RolldownPluginOption,
} from 'rolldown'
import { filename_js_to_dts, RE_JS } from 'rolldown-plugin-dts/internal'
import { importGlobPlugin } from 'rolldown/experimental'
import pkg from '../../package.json' with { type: 'json' }
import { mergeUserOptions } from '../config/options.ts'
Expand Down Expand Up @@ -117,9 +118,10 @@ async function resolveInputOptions(

if (dts) {
const { dts: dtsPlugin } = await import('rolldown-plugin-dts')
const { cjsReexport: _, ...dtsPluginOptions } = dts
const options: DtsOptions = {
tsconfig,
...dts,
...dtsPluginOptions,
}

if (format === 'es') {
Expand All @@ -132,6 +134,8 @@ async function resolveInputOptions(
cjsDefault,
}),
)
} else if (dts.cjsReexport && isDualFormat) {
plugins.push(CjsDtsReexportPlugin())
}
}
let cssPostPlugins: Plugin[] | undefined
Expand Down Expand Up @@ -339,6 +343,31 @@ function handlePluginInspect(plugins: RolldownPluginOption) {
}
}

export function CjsDtsReexportPlugin(): Plugin {
return {
name: 'tsdown:cjs-dts-reexport',
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk') continue

if (!chunk.fileName.endsWith('.cjs') && !chunk.fileName.endsWith('.js'))
continue

const dMtsBasename = path.basename(
chunk.fileName.replace(RE_JS, '.d.mts'),
)
const content = `export * from './${dMtsBasename}'\n`

this.emitFile({
type: 'prebuilt-chunk',
fileName: filename_js_to_dts(chunk.fileName),
code: content,
})
}
},
}
}

export function CssGuardPlugin(): Plugin {
return {
name: 'tsdown:css-guard',
Expand Down
40 changes: 40 additions & 0 deletions tests/__snapshots__/cjs-dts-reexport.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## folder/index.cjs

```cjs
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region index.ts
function hello() {
console.log("Hello!");
}
//#endregion
exports.hello = hello;

```

## folder/index.d.cts

```cts
export * from './index.d.mts'

```

## folder/index.d.mts

```mts
//#region index.d.ts
declare function hello(): void;
//#endregion
export { hello };
```

## folder/index.mjs

```mjs
//#region index.ts
function hello() {
console.log("Hello!");
}
//#endregion
export { hello };

```
19 changes: 19 additions & 0 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ test('cjs default', async (context) => {
})
})

test('cjs dts reexport', async (context) => {
const files = {
'index.ts': `export function hello(): void {
console.log('Hello!')
}`,
}
await testBuild({
context,
files,
options: {
entry: {
'folder/index': 'index.ts',
},
format: ['esm', 'cjs'],
dts: { cjsReexport: true },
},
})
})

test('fixed extension', async (context) => {
const files = {
'index.ts': `export default 10`,
Expand Down
Loading