Skip to content

Commit 0fff075

Browse files
committed
feat: add autoAddExts feature
1 parent aa72de6 commit 0fff075

File tree

6 files changed

+123
-28
lines changed

6 files changed

+123
-28
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,27 @@ export interface Options {
9393

9494
/** An extra directory layer for output files. */
9595
extraOutdir?: string
96+
/** Automatically add `.js` extension to resolve in `Node16` + ESM mode. */
97+
autoAddExts?: boolean
9698
}
9799
```
98100

101+
### `autoAddExts`
102+
103+
Automatically add `.js` extension to resolve in Node 16+ ESM mode.
104+
105+
```ts
106+
// index.d.ts
107+
import {} from './foo'
108+
```
109+
110+
With `autoAddExts`, it will be transformed to:
111+
112+
```ts
113+
// index.d.ts
114+
import {} from './foo.js'
115+
```
116+
99117
## Sponsors
100118

101119
<p align="center">
@@ -107,3 +125,7 @@ export interface Options {
107125
## License
108126

109127
[MIT](./LICENSE) License © 2024-PRESENT [三咲智子](https://github.com/sxzz)
128+
129+
```
130+
131+
```

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
},
9696
"dependencies": {
9797
"@rollup/pluginutils": "^5.1.0",
98+
"magic-string": "^0.30.11",
9899
"oxc-parser": "^0.27.0",
99100
"unplugin": "^1.14.0"
100101
},
@@ -103,6 +104,7 @@
103104
"@sxzz/eslint-config": "^4.2.0",
104105
"@sxzz/prettier-config": "^2.0.2",
105106
"@types/node": "^22.5.4",
107+
"@typescript-eslint/typescript-estree": "^8.5.0",
106108
"bumpp": "^9.5.2",
107109
"esbuild": "^0.23.1",
108110
"eslint": "^9.10.0",

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type Options = {
88
ignoreErrors?: boolean
99
/** An extra directory layer for output files. */
1010
extraOutdir?: string
11+
/** Automatically add `.js` extension to resolve in `Node16` + ESM mode. */
12+
autoAddExts?: boolean
1113
} & (
1214
| {
1315
/**
@@ -42,5 +44,6 @@ export function resolveOptions(options: Options): OptionsResolved {
4244
transformer: options.transformer || 'typescript',
4345
ignoreErrors: options.ignoreErrors || false,
4446
extraOutdir: options.extraOutdir,
47+
autoAddExts: options.autoAddExts || false,
4548
}
4649
}

src/index.ts

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdir, readFile, writeFile } from 'node:fs/promises'
22
import path from 'node:path'
33
import { createFilter } from '@rollup/pluginutils'
4+
import MagicString from 'magic-string'
45
import { parseAsync } from 'oxc-parser'
56
import {
67
createUnplugin,
@@ -15,6 +16,7 @@ import {
1516
tsTransform,
1617
type TransformResult,
1718
} from './core/transformer'
19+
import type { TSESTree } from '@typescript-eslint/typescript-estree'
1820
import type { PluginBuild } from 'esbuild'
1921
import type { Plugin, PluginContext } from 'rollup'
2022

@@ -94,6 +96,41 @@ export const IsolatedDecl: UnpluginInstance<Options | undefined, false> =
9496
code: string,
9597
id: string,
9698
): Promise<undefined> {
99+
let program: TSESTree.Program | undefined
100+
try {
101+
program = JSON.parse(
102+
(await parseAsync(code, { sourceFilename: id })).program,
103+
)
104+
} catch {}
105+
106+
if (options.autoAddExts && program) {
107+
const imports = program.body.filter(
108+
(node) =>
109+
node.type === 'ImportDeclaration' ||
110+
node.type === 'ExportAllDeclaration' ||
111+
node.type === 'ExportNamedDeclaration',
112+
)
113+
const s = new MagicString(code)
114+
for (const i of imports) {
115+
if (!i.source || path.basename(i.source.value).includes('.')) {
116+
continue
117+
}
118+
119+
const resolved = await resolve(context, i.source.value, id)
120+
if (!resolved || resolved.external) continue
121+
if (resolved.id.endsWith('.ts')) {
122+
s.overwrite(
123+
// @ts-expect-error
124+
i.source.start,
125+
// @ts-expect-error
126+
i.source.end,
127+
JSON.stringify(`${i.source.value}.js`),
128+
)
129+
}
130+
}
131+
code = s.toString()
132+
}
133+
97134
let result: TransformResult
98135
switch (options.transformer) {
99136
case 'oxc':
@@ -120,25 +157,45 @@ export const IsolatedDecl: UnpluginInstance<Options | undefined, false> =
120157
}
121158
addOutput(id, sourceText)
122159

123-
let program: any
124-
try {
125-
program = JSON.parse(
126-
(await parseAsync(code, { sourceFilename: id })).program,
127-
)
128-
} catch {
129-
return
130-
}
131-
const typeImports = program.body.filter((node: any) => {
132-
if (node.type !== 'ImportDeclaration') return false
133-
if (node.importKind === 'type') return true
134-
return (node.specifiers || []).every(
135-
(spec: any) =>
136-
spec.type === 'ImportSpecifier' && spec.importKind === 'type',
137-
)
138-
})
160+
if (!program) return
161+
const typeImports = program.body.filter(
162+
(
163+
node,
164+
): node is
165+
| TSESTree.ImportDeclaration
166+
| TSESTree.ExportNamedDeclaration
167+
| TSESTree.ExportAllDeclaration => {
168+
if (node.type === 'ImportDeclaration') {
169+
if (node.importKind === 'type') return true
170+
return (
171+
node.specifiers &&
172+
node.specifiers.every(
173+
(spec) =>
174+
spec.type === 'ImportSpecifier' && spec.importKind === 'type',
175+
)
176+
)
177+
}
178+
if (
179+
node.type === 'ExportNamedDeclaration' ||
180+
node.type === 'ExportAllDeclaration'
181+
) {
182+
if (node.exportKind === 'type') return true
183+
return (
184+
node.type === 'ExportNamedDeclaration' &&
185+
node.specifiers &&
186+
node.specifiers.every(
187+
(spec) =>
188+
spec.type === 'ExportSpecifier' && spec.exportKind === 'type',
189+
)
190+
)
191+
}
192+
return false
193+
},
194+
)
139195

140196
for (const i of typeImports) {
141-
const resolved = await resolve(context, i.source.value, id)
197+
if (!i.source) continue
198+
const resolved = (await resolve(context, i.source.value, id))?.id
142199
if (resolved && filter(resolved) && !outputFiles[stripExt(resolved)]) {
143200
let source: string
144201
try {
@@ -212,22 +269,23 @@ export const IsolatedDecl: UnpluginInstance<Options | undefined, false> =
212269
}
213270
})
214271

215-
const resolve = async (
272+
async function resolve(
216273
context: UnpluginBuildContext,
217274
id: string,
218275
importer: string,
219-
) => {
276+
): Promise<{ id: string; external: boolean } | undefined> {
220277
const nativeContext = context.getNativeBuildContext?.()
221278
if (nativeContext?.framework === 'esbuild') {
222-
return (
223-
await nativeContext.build.resolve(id, {
224-
importer,
225-
resolveDir: path.dirname(importer),
226-
kind: 'import-statement',
227-
})
228-
).path
279+
const resolved = await nativeContext.build.resolve(id, {
280+
importer,
281+
resolveDir: path.dirname(importer),
282+
kind: 'import-statement',
283+
})
284+
return { id: resolved.path, external: resolved.external }
229285
}
230-
return (await (context as PluginContext).resolve(id, importer))?.id
286+
const resolved = await (context as PluginContext).resolve(id, importer)
287+
if (!resolved) return
288+
return { id: resolved.id, external: !!resolved.external }
231289
}
232290

233291
function stripExt(filename: string) {

tsdown.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@ export default defineConfig({
55
entry: ['./src/*.ts'],
66
format: ['cjs', 'esm'],
77
clean: true,
8-
plugins: [IsolatedDecl()],
8+
plugins: [
9+
IsolatedDecl({
10+
autoAddExts: true,
11+
}),
12+
],
913
})

0 commit comments

Comments
 (0)