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
28 changes: 24 additions & 4 deletions server/api/registry/analysis/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
} from '#shared/utils/constants'
import { parseRepoUrl } from '#shared/utils/git-providers'
import { encodePackageName } from '#shared/utils/npm'
import { flattenFileTree } from '#server/utils/import-resolver'
import { getPackageFileTree } from '#server/utils/file-tree'
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'

interface AnalysisPackageJson extends ExtendedPackageJson {
Expand Down Expand Up @@ -50,18 +52,36 @@ export default defineCachedEventHandler(
`${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
)

// Only check for @types package if the package doesn't ship its own types
let typesPackage: TypesPackageInfo | undefined
let files: Set<string> | undefined

// Only check for @types and files when the package doesn't ship its own types
if (!hasBuiltInTypes(pkg)) {
const typesPkgName = getTypesPackageName(packageName)
typesPackage = await fetchTypesPackageInfo(typesPkgName)
const resolvedVersion = pkg.version ?? version ?? 'latest'

// Fetch @types info and file tree in parallel — they are independent
const [typesResult, fileTreeResult] = await Promise.allSettled([
fetchTypesPackageInfo(typesPkgName),
getPackageFileTree(packageName, resolvedVersion),
])

if (typesResult.status === 'fulfilled') {
typesPackage = typesResult.value
}
if (fileTreeResult.status === 'fulfilled') {
files = flattenFileTree(fileTreeResult.value.tree)
}
}

// Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app)
// Only show if the packages are actually associated (same maintainers or same org)
const createPackage = await findAssociatedCreatePackage(packageName, pkg)

const analysis = analyzePackage(pkg, { typesPackage, createPackage })
const analysis = analyzePackage(pkg, {
typesPackage,
createPackage,
files,
})
const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme)

return {
Expand Down
100 changes: 98 additions & 2 deletions shared/utils/package-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,101 @@ export function getCreateShortName(createPackageName: string): string {
return createPackageName
}

/**
* Map of JS extensions to their corresponding declaration file extensions.
*/
const DECLARATION_EXTENSIONS: Record<string, string[]> = {
'.mjs': ['.d.mts', '.d.ts'],
'.cjs': ['.d.cts', '.d.ts'],
'.js': ['.d.ts', '.d.mts', '.d.cts'],
}

/**
* Collect concrete file paths from the exports field, skipping the "types"
* condition (which is already checked by analyzeExports).
*/
function collectExportPaths(exports: PackageExports, depth = 0): string[] {
if (depth > 10) return []
if (exports === null || exports === undefined) return []

if (typeof exports === 'string') {
return [exports]
}

if (Array.isArray(exports)) {
return exports.flatMap(item => collectExportPaths(item, depth + 1))
}

if (typeof exports === 'object') {
const paths: string[] = []
for (const [key, value] of Object.entries(exports)) {
// Skip "types" condition — already detected by analyzeExports
if (key === 'types') continue
paths.push(...collectExportPaths(value, depth + 1))
}
return paths
}

return []
}

/**
* Normalize a path by stripping a leading "./" prefix.
*/
function stripRelativePrefix(p: string): string {
return p.startsWith('./') ? p.slice(2) : p
}

/**
* Derive expected declaration file paths from a JS entry point path.
* e.g. "./dist/index.mjs" -> ["dist/index.d.mts", "dist/index.d.ts"]
*/
function getDeclCandidates(entryPath: string): string[] {
const normalized = stripRelativePrefix(entryPath)
for (const [ext, declExts] of Object.entries(DECLARATION_EXTENSIONS)) {
if (normalized.endsWith(ext)) {
const base = normalized.slice(0, -ext.length)
return declExts.map(de => base + de)
}
}
return []
}

/**
* Check if declaration files exist for any of the package's entry points.
* Derives expected declaration paths from exports/main/module entry points
* (e.g. .d.mts for .mjs) and checks if they exist in the published files.
*/
function hasImplicitTypesForEntryPoints(pkg: ExtendedPackageJson, files: Set<string>): boolean {
const entryPaths: string[] = []

if (pkg.exports) {
entryPaths.push(...collectExportPaths(pkg.exports))
}
if (pkg.main) {
entryPaths.push(pkg.main)
}
if (pkg.module) {
entryPaths.push(pkg.module)
}

for (const entryPath of entryPaths) {
const candidates = getDeclCandidates(entryPath)
if (candidates.some(c => files.has(c))) {
return true
}
}

return false
}

/**
* Detect TypeScript types status for a package
*/
export function detectTypesStatus(
pkg: ExtendedPackageJson,
typesPackageInfo?: TypesPackageInfo,
files?: Set<string>,
): TypesStatus {
// Check for built-in types
if (pkg.types || pkg.typings) {
Expand All @@ -239,6 +328,12 @@ export function detectTypesStatus(
}
}

// Check for implicit types by deriving expected declaration file paths from
// entry points (e.g. .d.mts for .mjs) and checking if they exist in the package
if (files && hasImplicitTypesForEntryPoints(pkg, files)) {
return { kind: 'included' }
}

// Check for @types package
if (typesPackageInfo) {
return {
Expand Down Expand Up @@ -289,6 +384,8 @@ export function getTypesPackageName(packageName: string): string {
export interface AnalyzePackageOptions {
typesPackage?: TypesPackageInfo
createPackage?: CreatePackageInfo
/** Flattened package file paths for implicit types detection (e.g. .d.mts next to .mjs) */
files?: Set<string>
}

/**
Expand All @@ -299,8 +396,7 @@ export function analyzePackage(
options?: AnalyzePackageOptions,
): PackageAnalysis {
const moduleFormat = detectModuleFormat(pkg)

const types = detectTypesStatus(pkg, options?.typesPackage)
const types = detectTypesStatus(pkg, options?.typesPackage, options?.files)

return {
moduleFormat,
Expand Down
101 changes: 101 additions & 0 deletions test/unit/shared/utils/package-analysis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,107 @@ describe('detectTypesStatus', () => {
it('returns none when no types detected', () => {
expect(detectTypesStatus({})).toEqual({ kind: 'none' })
})

it('detects included types when matching declaration file exists for entry point', () => {
expect(
detectTypesStatus(
{ type: 'module', exports: { '.': './dist/index.mjs' } },
undefined,
new Set(['dist/index.mjs', 'dist/index.d.mts']),
),
).toEqual({ kind: 'included' })
})

it('does not detect types from unrelated .d.ts files in the package', () => {
expect(
detectTypesStatus(
{ type: 'module', exports: { '.': './dist/index.mjs' } },
undefined,
new Set(['dist/index.mjs', 'env.d.ts', 'shims-vue.d.ts']),
),
).toEqual({ kind: 'none' })
})
})

describe('detectTypesStatus implicit types from entry points', () => {
it('finds .d.mts matching .mjs export entry point', () => {
expect(
detectTypesStatus(
{ type: 'module', exports: { '.': './dist/index.mjs' } },
undefined,
new Set(['dist/index.d.mts']),
),
).toEqual({ kind: 'included' })
})

it('finds .d.cts matching .cjs export entry point', () => {
expect(
detectTypesStatus(
{ exports: { '.': { require: './dist/index.cjs' } } },
undefined,
new Set(['dist/index.d.cts']),
),
).toEqual({ kind: 'included' })
})

it('finds .d.ts matching .js export entry point', () => {
expect(
detectTypesStatus(
{ exports: { '.': './dist/index.js' } },
undefined,
new Set(['dist/index.d.ts']),
),
).toEqual({ kind: 'included' })
})

it('finds .d.mts matching .mjs main entry point', () => {
expect(
detectTypesStatus(
{ type: 'module', main: 'dist/index.mjs' },
undefined,
new Set(['dist/index.d.mts']),
),
).toEqual({ kind: 'included' })
})

it('finds .d.ts matching .js module entry point', () => {
expect(
detectTypesStatus({ module: './dist/index.js' }, undefined, new Set(['dist/index.d.ts'])),
).toEqual({ kind: 'included' })
})

it('returns none when no declaration file matches any entry point', () => {
expect(
detectTypesStatus(
{ type: 'module', exports: { '.': './dist/index.mjs' } },
undefined,
new Set(['dist/other.d.mts', 'types/env.d.ts']),
),
).toEqual({ kind: 'none' })
})
})

describe('analyzePackage with files (implicit types)', () => {
it('detects included types when matching declaration file exists for entry point', () => {
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
const files = new Set(['dist/index.mjs', 'dist/index.d.mts'])
const result = analyzePackage(pkg, { files })
expect(result.types).toEqual({ kind: 'included' })
})

it('returns none when no declaration file matches entry point', () => {
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
const files = new Set(['dist/index.mjs'])
const result = analyzePackage(pkg, { files })
expect(result.types).toEqual({ kind: 'none' })
})

it('returns none when only unrelated .d.ts files exist', () => {
const pkg = { type: 'module' as const, exports: { '.': './dist/index.mjs' } }
const files = new Set(['dist/index.mjs', 'env.d.ts'])
const result = analyzePackage(pkg, { files })
expect(result.types).toEqual({ kind: 'none' })
})
})

describe('getTypesPackageName', () => {
Expand Down
Loading