Skip to content

Commit d8a01e6

Browse files
eryue0220danielroe
andauthored
fix: no types declaration in package.json (#1674)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent a38a2f7 commit d8a01e6

File tree

3 files changed

+223
-6
lines changed

3 files changed

+223
-6
lines changed

server/api/registry/analysis/[...pkg].get.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
} from '#shared/utils/constants'
2424
import { parseRepoUrl } from '#shared/utils/git-providers'
2525
import { encodePackageName } from '#shared/utils/npm'
26+
import { flattenFileTree } from '#server/utils/import-resolver'
27+
import { getPackageFileTree } from '#server/utils/file-tree'
2628
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'
2729

2830
interface AnalysisPackageJson extends ExtendedPackageJson {
@@ -50,18 +52,36 @@ export default defineCachedEventHandler(
5052
`${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
5153
)
5254

53-
// Only check for @types package if the package doesn't ship its own types
5455
let typesPackage: TypesPackageInfo | undefined
56+
let files: Set<string> | undefined
57+
58+
// Only check for @types and files when the package doesn't ship its own types
5559
if (!hasBuiltInTypes(pkg)) {
5660
const typesPkgName = getTypesPackageName(packageName)
57-
typesPackage = await fetchTypesPackageInfo(typesPkgName)
61+
const resolvedVersion = pkg.version ?? version ?? 'latest'
62+
63+
// Fetch @types info and file tree in parallel — they are independent
64+
const [typesResult, fileTreeResult] = await Promise.allSettled([
65+
fetchTypesPackageInfo(typesPkgName),
66+
getPackageFileTree(packageName, resolvedVersion),
67+
])
68+
69+
if (typesResult.status === 'fulfilled') {
70+
typesPackage = typesResult.value
71+
}
72+
if (fileTreeResult.status === 'fulfilled') {
73+
files = flattenFileTree(fileTreeResult.value.tree)
74+
}
5875
}
5976

6077
// Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app)
6178
// Only show if the packages are actually associated (same maintainers or same org)
6279
const createPackage = await findAssociatedCreatePackage(packageName, pkg)
63-
64-
const analysis = analyzePackage(pkg, { typesPackage, createPackage })
80+
const analysis = analyzePackage(pkg, {
81+
typesPackage,
82+
createPackage,
83+
files,
84+
})
6585
const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme)
6686

6787
return {

shared/utils/package-analysis.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,101 @@ export function getCreateShortName(createPackageName: string): string {
219219
return createPackageName
220220
}
221221

222+
/**
223+
* Map of JS extensions to their corresponding declaration file extensions.
224+
*/
225+
const DECLARATION_EXTENSIONS: Record<string, string[]> = {
226+
'.mjs': ['.d.mts', '.d.ts'],
227+
'.cjs': ['.d.cts', '.d.ts'],
228+
'.js': ['.d.ts', '.d.mts', '.d.cts'],
229+
}
230+
231+
/**
232+
* Collect concrete file paths from the exports field, skipping the "types"
233+
* condition (which is already checked by analyzeExports).
234+
*/
235+
function collectExportPaths(exports: PackageExports, depth = 0): string[] {
236+
if (depth > 10) return []
237+
if (exports === null || exports === undefined) return []
238+
239+
if (typeof exports === 'string') {
240+
return [exports]
241+
}
242+
243+
if (Array.isArray(exports)) {
244+
return exports.flatMap(item => collectExportPaths(item, depth + 1))
245+
}
246+
247+
if (typeof exports === 'object') {
248+
const paths: string[] = []
249+
for (const [key, value] of Object.entries(exports)) {
250+
// Skip "types" condition — already detected by analyzeExports
251+
if (key === 'types') continue
252+
paths.push(...collectExportPaths(value, depth + 1))
253+
}
254+
return paths
255+
}
256+
257+
return []
258+
}
259+
260+
/**
261+
* Normalize a path by stripping a leading "./" prefix.
262+
*/
263+
function stripRelativePrefix(p: string): string {
264+
return p.startsWith('./') ? p.slice(2) : p
265+
}
266+
267+
/**
268+
* Derive expected declaration file paths from a JS entry point path.
269+
* e.g. "./dist/index.mjs" -> ["dist/index.d.mts", "dist/index.d.ts"]
270+
*/
271+
function getDeclCandidates(entryPath: string): string[] {
272+
const normalized = stripRelativePrefix(entryPath)
273+
for (const [ext, declExts] of Object.entries(DECLARATION_EXTENSIONS)) {
274+
if (normalized.endsWith(ext)) {
275+
const base = normalized.slice(0, -ext.length)
276+
return declExts.map(de => base + de)
277+
}
278+
}
279+
return []
280+
}
281+
282+
/**
283+
* Check if declaration files exist for any of the package's entry points.
284+
* Derives expected declaration paths from exports/main/module entry points
285+
* (e.g. .d.mts for .mjs) and checks if they exist in the published files.
286+
*/
287+
function hasImplicitTypesForEntryPoints(pkg: ExtendedPackageJson, files: Set<string>): boolean {
288+
const entryPaths: string[] = []
289+
290+
if (pkg.exports) {
291+
entryPaths.push(...collectExportPaths(pkg.exports))
292+
}
293+
if (pkg.main) {
294+
entryPaths.push(pkg.main)
295+
}
296+
if (pkg.module) {
297+
entryPaths.push(pkg.module)
298+
}
299+
300+
for (const entryPath of entryPaths) {
301+
const candidates = getDeclCandidates(entryPath)
302+
if (candidates.some(c => files.has(c))) {
303+
return true
304+
}
305+
}
306+
307+
return false
308+
}
309+
222310
/**
223311
* Detect TypeScript types status for a package
224312
*/
225313
export function detectTypesStatus(
226314
pkg: ExtendedPackageJson,
227315
typesPackageInfo?: TypesPackageInfo,
316+
files?: Set<string>,
228317
): TypesStatus {
229318
// Check for built-in types
230319
if (pkg.types || pkg.typings) {
@@ -239,6 +328,12 @@ export function detectTypesStatus(
239328
}
240329
}
241330

331+
// Check for implicit types by deriving expected declaration file paths from
332+
// entry points (e.g. .d.mts for .mjs) and checking if they exist in the package
333+
if (files && hasImplicitTypesForEntryPoints(pkg, files)) {
334+
return { kind: 'included' }
335+
}
336+
242337
// Check for @types package
243338
if (typesPackageInfo) {
244339
return {
@@ -289,6 +384,8 @@ export function getTypesPackageName(packageName: string): string {
289384
export interface AnalyzePackageOptions {
290385
typesPackage?: TypesPackageInfo
291386
createPackage?: CreatePackageInfo
387+
/** Flattened package file paths for implicit types detection (e.g. .d.mts next to .mjs) */
388+
files?: Set<string>
292389
}
293390

294391
/**
@@ -299,8 +396,7 @@ export function analyzePackage(
299396
options?: AnalyzePackageOptions,
300397
): PackageAnalysis {
301398
const moduleFormat = detectModuleFormat(pkg)
302-
303-
const types = detectTypesStatus(pkg, options?.typesPackage)
399+
const types = detectTypesStatus(pkg, options?.typesPackage, options?.files)
304400

305401
return {
306402
moduleFormat,

test/unit/shared/utils/package-analysis.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,107 @@ describe('detectTypesStatus', () => {
166166
it('returns none when no types detected', () => {
167167
expect(detectTypesStatus({})).toEqual({ kind: 'none' })
168168
})
169+
170+
it('detects included types when matching declaration file exists for entry point', () => {
171+
expect(
172+
detectTypesStatus(
173+
{ type: 'module', exports: { '.': './dist/index.mjs' } },
174+
undefined,
175+
new Set(['dist/index.mjs', 'dist/index.d.mts']),
176+
),
177+
).toEqual({ kind: 'included' })
178+
})
179+
180+
it('does not detect types from unrelated .d.ts files in the package', () => {
181+
expect(
182+
detectTypesStatus(
183+
{ type: 'module', exports: { '.': './dist/index.mjs' } },
184+
undefined,
185+
new Set(['dist/index.mjs', 'env.d.ts', 'shims-vue.d.ts']),
186+
),
187+
).toEqual({ kind: 'none' })
188+
})
189+
})
190+
191+
describe('detectTypesStatus implicit types from entry points', () => {
192+
it('finds .d.mts matching .mjs export entry point', () => {
193+
expect(
194+
detectTypesStatus(
195+
{ type: 'module', exports: { '.': './dist/index.mjs' } },
196+
undefined,
197+
new Set(['dist/index.d.mts']),
198+
),
199+
).toEqual({ kind: 'included' })
200+
})
201+
202+
it('finds .d.cts matching .cjs export entry point', () => {
203+
expect(
204+
detectTypesStatus(
205+
{ exports: { '.': { require: './dist/index.cjs' } } },
206+
undefined,
207+
new Set(['dist/index.d.cts']),
208+
),
209+
).toEqual({ kind: 'included' })
210+
})
211+
212+
it('finds .d.ts matching .js export entry point', () => {
213+
expect(
214+
detectTypesStatus(
215+
{ exports: { '.': './dist/index.js' } },
216+
undefined,
217+
new Set(['dist/index.d.ts']),
218+
),
219+
).toEqual({ kind: 'included' })
220+
})
221+
222+
it('finds .d.mts matching .mjs main entry point', () => {
223+
expect(
224+
detectTypesStatus(
225+
{ type: 'module', main: 'dist/index.mjs' },
226+
undefined,
227+
new Set(['dist/index.d.mts']),
228+
),
229+
).toEqual({ kind: 'included' })
230+
})
231+
232+
it('finds .d.ts matching .js module entry point', () => {
233+
expect(
234+
detectTypesStatus({ module: './dist/index.js' }, undefined, new Set(['dist/index.d.ts'])),
235+
).toEqual({ kind: 'included' })
236+
})
237+
238+
it('returns none when no declaration file matches any entry point', () => {
239+
expect(
240+
detectTypesStatus(
241+
{ type: 'module', exports: { '.': './dist/index.mjs' } },
242+
undefined,
243+
new Set(['dist/other.d.mts', 'types/env.d.ts']),
244+
),
245+
).toEqual({ kind: 'none' })
246+
})
247+
})
248+
249+
describe('analyzePackage with files (implicit types)', () => {
250+
it('detects included types when matching declaration file exists for entry point', () => {
251+
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
252+
const files = new Set(['dist/index.mjs', 'dist/index.d.mts'])
253+
const result = analyzePackage(pkg, { files })
254+
expect(result.types).toEqual({ kind: 'included' })
255+
})
256+
257+
it('returns none when no declaration file matches entry point', () => {
258+
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
259+
const files = new Set(['dist/index.mjs'])
260+
const result = analyzePackage(pkg, { files })
261+
expect(result.types).toEqual({ kind: 'none' })
262+
})
263+
264+
it('returns none when only unrelated .d.ts files exist', () => {
265+
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
266+
const files = new Set(['dist/index.mjs', 'env.d.ts'])
267+
const result = analyzePackage(pkg, { files })
268+
expect(result.types).toEqual({ kind: 'none' })
269+
})
169270
})
170271

171272
describe('getTypesPackageName', () => {

0 commit comments

Comments
 (0)