Skip to content

Commit eb3eb0d

Browse files
committed
fix: generate declaration files with correct references
1 parent e256f5b commit eb3eb0d

File tree

2 files changed

+126
-98
lines changed

2 files changed

+126
-98
lines changed

src/index.ts

Lines changed: 57 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import fs from 'node:fs'
12
import p from 'node:path'
23
import process from 'node:process'
34
import type { BunPlugin } from 'bun'
45
import ts from 'typescript'
56

6-
interface TsOptions {
7+
export interface TsOptions {
78
rootDir: string
89
base: string
910
declaration: boolean
@@ -14,7 +15,7 @@ interface TsOptions {
1415
[index: string]: any
1516
}
1617

17-
interface DtsOptions {
18+
export interface DtsOptions {
1819
/**
1920
* The base directory of the source files. If not provided, it
2021
* will use the current working directory of the process.
@@ -71,91 +72,76 @@ export async function generate(entryPoints: string | string[], options?: DtsOpti
7172
const root = (options?.root ?? 'src').replace(/^\.\//, '')
7273

7374
try {
74-
const configJson = ts.readConfigFile(path, ts.sys.readFile).config
75+
const configFile = ts.readConfigFile(path, ts.sys.readFile)
76+
if (configFile.error) {
77+
throw new Error(`Failed to read tsconfig: ${configFile.error.messageText}`)
78+
}
79+
7580
const cwd = options?.cwd ?? process.cwd()
7681
const base = options?.base ?? cwd
77-
const rootDir = `${cwd}/${root}`
78-
79-
// Merge the base tsconfig with the package-specific one
80-
const mergedConfig = {
81-
...configJson,
82-
compilerOptions: {
83-
...configJson.compilerOptions,
84-
...options?.compiler,
85-
},
82+
const rootDir = p.resolve(cwd, root)
83+
84+
const parsedCommandLine = ts.parseJsonConfigFileContent(configFile.config, ts.sys, cwd)
85+
if (parsedCommandLine.errors.length) {
86+
throw new Error(`Failed to parse tsconfig: ${parsedCommandLine.errors.map((e) => e.messageText).join(', ')}`)
8687
}
8788

88-
const opts: TsOptions = {
89-
base,
90-
baseUrl: base,
91-
rootDir,
89+
const outDir = p.resolve(cwd, options?.outdir || parsedCommandLine.options.outDir || 'dist')
90+
91+
const compilerOptions: ts.CompilerOptions = {
92+
...parsedCommandLine.options,
93+
...options?.compiler,
9294
declaration: true,
9395
emitDeclarationOnly: true,
9496
noEmit: false,
95-
isolatedDeclarations: undefined,
96-
...(options?.include && { include: options.include }),
97+
declarationMap: true,
98+
outDir: outDir,
99+
rootDir: rootDir,
100+
incremental: false, // Disable incremental compilation
97101
}
98102

99-
const parsedConfig = ts.parseJsonConfigFileContent(mergedConfig, ts.sys, cwd, opts, path)
100-
parsedConfig.options.emitDeclarationOnly = true
101-
102-
// Use the outdir from options if provided, otherwise use the one from tsconfig
103-
const outDir = options?.outdir || parsedConfig.options.outDir || 'dist'
104-
105-
// Ensure outDir is relative to the package root, not the monorepo root
106-
parsedConfig.options.outDir = p.resolve(cwd, outDir)
107-
108-
const host = ts.createCompilerHost(parsedConfig.options)
109-
110-
// Custom transformers to modify the output path of declaration files
111-
const customTransformers: ts.CustomTransformers = {
112-
afterDeclarations: [
113-
(context) => {
114-
return (sourceFile) => {
115-
if ('isDeclarationFile' in sourceFile) {
116-
const originalFileName = sourceFile.fileName
117-
const entryPointName = p.basename(originalFileName, '.ts')
118-
const newFileName = p.join(parsedConfig.options.outDir || 'dist', `${entryPointName}.d.ts`)
119-
120-
return ts.factory.updateSourceFile(
121-
sourceFile,
122-
sourceFile.statements,
123-
sourceFile.isDeclarationFile,
124-
sourceFile.referencedFiles,
125-
sourceFile.typeReferenceDirectives,
126-
sourceFile.hasNoDefaultLib,
127-
[{ fileName: newFileName, pos: 0, end: 0 }],
128-
)
129-
}
130-
return sourceFile
131-
}
132-
},
133-
],
134-
}
103+
const host = ts.createCompilerHost(compilerOptions)
104+
105+
// console.log('Debug:', {
106+
// cwd,
107+
// rootDir,
108+
// outDir,
109+
// entryPoints: Array.isArray(entryPoints) ? entryPoints : [entryPoints],
110+
// })
135111

136112
const program = ts.createProgram({
137113
rootNames: Array.isArray(entryPoints) ? entryPoints : [entryPoints],
138-
options: parsedConfig.options,
114+
options: compilerOptions,
139115
host,
140116
})
141117

142-
program.emit(
143-
undefined,
144-
(fileName, data) => {
145-
if (fileName.endsWith('.d.ts')) {
146-
const outputPath = p.join(parsedConfig.options.outDir || 'dist', p.relative(rootDir, fileName))
147-
try {
148-
ts.sys.writeFile(outputPath, data)
149-
// console.log('Successfully wrote file:', outputPath)
150-
} catch (error) {
151-
console.error('Error writing file:', outputPath, error)
152-
}
118+
const emitResult = program.emit(undefined, (fileName, data) => {
119+
if (fileName.endsWith('.d.ts') || fileName.endsWith('.d.ts.map')) {
120+
const outputPath = p.join(outDir, p.relative(rootDir, fileName))
121+
const dir = p.dirname(outputPath)
122+
if (!fs.existsSync(dir)) {
123+
fs.mkdirSync(dir, { recursive: true })
153124
}
154-
},
155-
undefined,
156-
true, // Only emit declarations
157-
customTransformers,
158-
)
125+
fs.writeFileSync(outputPath, data)
126+
// console.log(`Generated: ${outputPath}`)
127+
}
128+
})
129+
130+
const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics)
131+
132+
if (allDiagnostics.length) {
133+
const formatHost: ts.FormatDiagnosticsHost = {
134+
getCanonicalFileName: (path) => path,
135+
getCurrentDirectory: ts.sys.getCurrentDirectory,
136+
getNewLine: () => ts.sys.newLine,
137+
}
138+
const message = ts.formatDiagnosticsWithColorAndContext(allDiagnostics, formatHost)
139+
console.error(message)
140+
}
141+
142+
if (emitResult.emitSkipped) {
143+
throw new Error('TypeScript compilation failed')
144+
}
159145
} catch (error) {
160146
console.error('Error generating types:', error)
161147
throw error

test/dts-auto.test.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
22
import fs from 'node:fs'
33
import path from 'node:path'
4+
import type { DtsOptions } from '../src'
45
import { dts, generate } from '../src/index'
56

6-
const tempDir = path.join(process.cwd(), 'test-temp')
7+
const tempDir = path.resolve(process.cwd(), 'test-temp')
78
const srcDir = path.join(tempDir, 'src')
89
const outDir = path.join(tempDir, 'dist')
910

@@ -53,31 +54,6 @@ describe('bun-plugin-dts-auto', () => {
5354
fs.rmSync(tempDir, { recursive: true, force: true })
5455
})
5556

56-
it('should generate declaration files', async () => {
57-
await generate(path.join(srcDir, 'sample.ts'), {
58-
cwd: tempDir,
59-
root: 'src',
60-
outdir: 'dist',
61-
})
62-
63-
const declarationFile = path.join(outDir, 'sample.d.ts')
64-
expect(fs.existsSync(declarationFile)).toBe(true)
65-
66-
const content = fs.readFileSync(declarationFile, 'utf-8')
67-
68-
// Check for the interface declaration
69-
expect(content).toContain('export interface User {')
70-
expect(content).toContain('id: number;')
71-
expect(content).toContain('name: string;')
72-
73-
// Check for the function declaration
74-
expect(content).toContain('export declare function greet(user: User): string;')
75-
76-
// Optionally, check for the reference comment and source map
77-
expect(content).toContain('/// <reference')
78-
expect(content).toContain('//# sourceMappingURL=sample.d.ts.map')
79-
})
80-
8157
it('should work as a Bun plugin', async () => {
8258
const plugin = dts({
8359
cwd: tempDir,
@@ -88,7 +64,6 @@ describe('bun-plugin-dts-auto', () => {
8864
expect(plugin.name).toBe('bun-plugin-dts-auto')
8965
expect(typeof plugin.setup).toBe('function')
9066

91-
// Mock the build object
9267
const mockBuild = {
9368
config: {
9469
entrypoints: [path.join(srcDir, 'sample.ts')],
@@ -103,4 +78,71 @@ describe('bun-plugin-dts-auto', () => {
10378
const declarationFile = path.join(outDir, 'sample.d.ts')
10479
expect(fs.existsSync(declarationFile)).toBe(true)
10580
})
81+
82+
it('should generate declaration files with correct references', async () => {
83+
const sampleFile1 = path.join(srcDir, 'sample1.ts')
84+
const sampleFile2 = path.join(srcDir, 'sample2.ts')
85+
86+
fs.writeFileSync(
87+
sampleFile1,
88+
`
89+
export interface User {
90+
id: number;
91+
name: string;
92+
}
93+
`,
94+
)
95+
96+
fs.writeFileSync(
97+
sampleFile2,
98+
`
99+
import { User } from './sample1';
100+
export function greet(user: User): string {
101+
return \`Hello, \${user.name}!\`;
102+
}
103+
`,
104+
)
105+
106+
await generate([sampleFile1, sampleFile2], {
107+
cwd: tempDir,
108+
root: 'src',
109+
outdir: 'dist',
110+
})
111+
112+
const declarationFile1 = path.join(outDir, 'sample1.d.ts')
113+
const declarationFile2 = path.join(outDir, 'sample2.d.ts')
114+
115+
expect(fs.existsSync(declarationFile1)).toBe(true)
116+
expect(fs.existsSync(declarationFile2)).toBe(true)
117+
118+
const content1 = fs.readFileSync(declarationFile1, 'utf-8')
119+
const content2 = fs.readFileSync(declarationFile2, 'utf-8')
120+
121+
// Use a regular expression to match either single or double quotes
122+
expect(content2).toMatch(/import\s*{\s*User\s*}\s*from\s*['"]\.\/sample1['"]/)
123+
})
124+
125+
it('should handle custom compiler options', async () => {
126+
const customOptions: DtsOptions = {
127+
cwd: tempDir,
128+
root: 'src',
129+
outdir: 'dist',
130+
compiler: {
131+
strict: false,
132+
declaration: true,
133+
declarationMap: true,
134+
},
135+
}
136+
137+
await generate(path.join(srcDir, 'sample.ts'), customOptions)
138+
139+
const declarationFile = path.join(outDir, 'sample.d.ts')
140+
const declarationMapFile = path.join(outDir, 'sample.d.ts.map')
141+
142+
expect(fs.existsSync(declarationFile)).toBe(true)
143+
expect(fs.existsSync(declarationMapFile)).toBe(true)
144+
145+
const content = fs.readFileSync(declarationFile, 'utf-8')
146+
expect(content).toContain('//# sourceMappingURL=sample.d.ts.map')
147+
})
106148
})

0 commit comments

Comments
 (0)