Skip to content

Commit 0b2767a

Browse files
brc-ddautofix-ci[bot]sxzz
authored
fix(deps): correctly resolve root @types packages for dts deep imports (#852)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Kevin Deng <sxzz@sxzz.moe>
1 parent a6b2e4b commit 0b2767a

File tree

4 files changed

+202
-24
lines changed

4 files changed

+202
-24
lines changed

src/features/deps.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getTypesPackageName, parseNodeModulesPath, parsePackageSpecifier } from './deps.ts'
3+
4+
describe('parsePackageSpecifier', () => {
5+
it('parses a simple package name', () => {
6+
expect(parsePackageSpecifier('lodash')).toEqual(['lodash', ''])
7+
})
8+
9+
it('parses a simple package with subpath', () => {
10+
expect(parsePackageSpecifier('lodash/get')).toEqual(['lodash', '/get'])
11+
})
12+
13+
it('parses a simple package with deep subpath', () => {
14+
expect(parsePackageSpecifier('markdown-it/lib/token.mjs')).toEqual([
15+
'markdown-it',
16+
'/lib/token.mjs',
17+
])
18+
})
19+
20+
it('parses a scoped package name', () => {
21+
expect(parsePackageSpecifier('@scope/pkg')).toEqual(['@scope/pkg', ''])
22+
})
23+
24+
it('parses a scoped package with subpath', () => {
25+
expect(parsePackageSpecifier('@scope/pkg/subpath')).toEqual([
26+
'@scope/pkg',
27+
'/subpath',
28+
])
29+
})
30+
})
31+
32+
describe('parseNodeModulesPath', () => {
33+
it('returns undefined for paths without node_modules', () => {
34+
expect(parseNodeModulesPath('/project/project/src/index.ts')).toBeUndefined()
35+
})
36+
37+
it('parses a simple package in node_modules', () => {
38+
expect(parseNodeModulesPath('/project/node_modules/lodash/index.js')).toEqual([
39+
'lodash',
40+
'/index.js',
41+
'/project/node_modules/lodash',
42+
])
43+
})
44+
45+
it('parses a scoped package in node_modules', () => {
46+
expect(
47+
parseNodeModulesPath('/project/node_modules/@scope/pkg/dist/index.js'),
48+
).toEqual([
49+
'@scope/pkg',
50+
'/dist/index.js',
51+
'/project/node_modules/@scope/pkg',
52+
])
53+
})
54+
55+
it('uses the last node_modules segment for nested deps', () => {
56+
expect(
57+
parseNodeModulesPath('/project/node_modules/foo/node_modules/bar/lib/utils.js'),
58+
).toEqual([
59+
'bar',
60+
'/lib/utils.js',
61+
'/project/node_modules/foo/node_modules/bar',
62+
])
63+
})
64+
65+
it('handles package root without subpath', () => {
66+
expect(parseNodeModulesPath('/project/node_modules/lodash')).toEqual([
67+
'lodash',
68+
'',
69+
'/project/node_modules/lodash',
70+
])
71+
})
72+
73+
it('normalizes backslashes on Windows-style paths', () => {
74+
expect(
75+
parseNodeModulesPath(String.raw`C:\project\node_modules\lodash\index.js`),
76+
).toEqual(['lodash', '/index.js', 'C:/project/node_modules/lodash'])
77+
})
78+
})
79+
80+
describe('getTypesPackageName', () => {
81+
it('maps deep imports to the root DefinitelyTyped package', () => {
82+
expect(getTypesPackageName('markdown-it/lib/token.mjs')).toBe(
83+
'@types/markdown-it',
84+
)
85+
})
86+
87+
it('maps deep imports from scoped packages to the scoped DefinitelyTyped package', () => {
88+
expect(getTypesPackageName('@scope/pkg/subpath')).toBe('@types/scope__pkg')
89+
})
90+
})

src/features/deps.ts

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export function DepsPlugin(
218218
if (chunk.type === 'asset') continue
219219

220220
for (const id of chunk.moduleIds) {
221-
const parsed = await parseBundledDep(id)
221+
const parsed = await readBundledDepInfo(id)
222222
if (!parsed) continue
223223

224224
deps.add(parsed.name)
@@ -314,13 +314,13 @@ export function DepsPlugin(
314314

315315
if (deps) {
316316
if (deps.includes(id) || deps.some((dep) => id.startsWith(`${dep}/`))) {
317-
const resolvedDep = await resolveDepPath(id, resolved)
317+
const resolvedDep = await resolveDepSubpath(id, resolved)
318318
return resolvedDep ? [true, resolvedDep] : true
319319
}
320320

321321
if (importer && RE_DTS.test(importer) && !id.startsWith('@types/')) {
322-
const typesName = `@types/${id.replace(/^@/, '').replaceAll('/', '__')}`
323-
if (deps.includes(typesName)) {
322+
const typesName = getTypesPackageName(id)
323+
if (typesName && deps.includes(typesName)) {
324324
return true
325325
}
326326
}
@@ -330,35 +330,35 @@ export function DepsPlugin(
330330
}
331331
}
332332

333-
function parseDepPath(
333+
export function parsePackageSpecifier(id: string): [name: string, subpath: string] {
334+
const [first, second] = id.split('/', 3)
335+
336+
const name = first[0] === '@' && second ? `${first}/${second}` : first
337+
const subpath = id.slice(name.length)
338+
339+
return [name, subpath]
340+
}
341+
342+
const NODE_MODULES = '/node_modules/'
343+
export function parseNodeModulesPath(
334344
id: string,
335345
): [name: string, subpath: string, root: string] | undefined {
336346
const slashed = slash(id)
337-
const lastNmIdx = slashed.lastIndexOf('/node_modules/')
347+
const lastNmIdx = slashed.lastIndexOf(NODE_MODULES)
338348
if (lastNmIdx === -1) return
339349

340-
const afterNm = slashed.slice(lastNmIdx + 14 /* '/node_modules/'.length */)
341-
const parts = afterNm.split('/')
350+
const afterNm = slashed.slice(lastNmIdx + NODE_MODULES.length)
342351

343-
let name: string
344-
if (parts[0][0] === '@') {
345-
name = `${parts[0]}/${parts[1]}`
346-
} else {
347-
name = parts[0]
348-
}
352+
const [name, subpath] = parsePackageSpecifier(afterNm)
353+
const root = slashed.slice(0, lastNmIdx + NODE_MODULES.length + name.length)
349354

350-
const root = slashed.slice(
351-
0,
352-
lastNmIdx + 14 /* '/node_modules/'.length */ + name.length,
353-
)
354-
355-
return [name, afterNm.slice(name.length), root]
355+
return [name, subpath, root]
356356
}
357357

358-
async function parseBundledDep(
358+
async function readBundledDepInfo(
359359
moduleId: string,
360360
): Promise<{ name: string; pkgName: string; version: string } | undefined> {
361-
const parsed = parseDepPath(moduleId)
361+
const parsed = parseNodeModulesPath(moduleId)
362362
if (!parsed) return
363363

364364
const [name, , root] = parsed
@@ -371,7 +371,14 @@ async function parseBundledDep(
371371
} catch {}
372372
}
373373

374-
async function resolveDepPath(id: string, resolved: ResolvedId | null) {
374+
export function getTypesPackageName(id: string): string | undefined {
375+
const name = parsePackageSpecifier(id)[0]
376+
if (!name) return
377+
378+
return `@types/${name.replace(/^@/, '').replace('/', '__')}`
379+
}
380+
381+
async function resolveDepSubpath(id: string, resolved: ResolvedId | null) {
375382
if (!resolved?.packageJsonPath) return
376383

377384
const parts = id.split('/')
@@ -390,7 +397,7 @@ async function resolveDepPath(id: string, resolved: ResolvedId | null) {
390397
// no `exports` field
391398
if (pkgJson.exports) return
392399

393-
const parsed = parseDepPath(resolved.id)
400+
const parsed = parseNodeModulesPath(resolved.id)
394401
if (!parsed) return
395402

396403
const result = parsed[0] + parsed[1]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## index.d.mts
2+
3+
```mts
4+
import Token from "foo/lib/token.mjs";
5+
6+
//#region node_modules/bar/types/index.d.ts
7+
interface AnchorOptions {
8+
getTokensText?(tokens: Token[]): string;
9+
}
10+
//#endregion
11+
export { type AnchorOptions };
12+
```
13+
14+
## index.mjs
15+
16+
```mjs
17+
export {};
18+
19+
```

tests/e2e.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,68 @@ test('externalize @types/foo', async (context) => {
881881
expect(fileMap['index.d.mts']).toContain('from "foo"')
882882
})
883883

884+
test('externalize deep imports covered by @types/foo', async (context) => {
885+
const node_modules = {
886+
'node_modules/foo/index.js': `export default function foo() {}`,
887+
'node_modules/foo/lib/token.mjs': `export default class Token {}`,
888+
'node_modules/foo/package.json': JSON.stringify({
889+
name: 'foo',
890+
version: '1.0.0',
891+
main: 'index.js',
892+
}),
893+
894+
'node_modules/bar/index.js': `export default function bar() {}`,
895+
'node_modules/bar/types/index.d.ts': `
896+
import { default as Token } from 'foo/lib/token.mjs'
897+
898+
export interface AnchorOptions {
899+
getTokensText?(tokens: Token[]): string
900+
}
901+
902+
declare function bar(): void
903+
904+
export default bar
905+
`,
906+
'node_modules/bar/package.json': JSON.stringify({
907+
name: 'bar',
908+
version: '1.0.0',
909+
main: 'index.js',
910+
types: 'types/index.d.ts',
911+
}),
912+
913+
'node_modules/@types/foo/index.d.ts': `export default interface Foo {}`,
914+
'node_modules/@types/foo/lib/token.d.ts': `
915+
export default class Token {
916+
tag: string
917+
}
918+
`,
919+
'node_modules/@types/foo/package.json': JSON.stringify({
920+
name: '@types/foo',
921+
version: '1.0.0',
922+
types: 'index.d.ts',
923+
}),
924+
}
925+
926+
const { fileMap } = await testBuild({
927+
context,
928+
files: {
929+
...node_modules,
930+
'index.ts': `export type { AnchorOptions } from 'bar'`,
931+
'package.json': JSON.stringify({
932+
name: 'test-pkg',
933+
version: '1.0.0',
934+
dependencies: {
935+
'@types/foo': '^1.0.0',
936+
},
937+
}),
938+
},
939+
options: { dts: true },
940+
})
941+
942+
expect(fileMap['index.d.mts']).toContain('from "foo/lib/token.mjs"')
943+
expect(fileMap['index.d.mts']).not.toContain('class Token')
944+
})
945+
884946
test('failOnWarn', async (context) => {
885947
const files = {
886948
'index.ts': `import 'unresolved'`,

0 commit comments

Comments
 (0)