diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts index 7ab47dcb6d..e9a2e906a0 100644 --- a/server/api/registry/analysis/[...pkg].get.ts +++ b/server/api/registry/analysis/[...pkg].get.ts @@ -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 { @@ -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 | 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 { diff --git a/shared/utils/package-analysis.ts b/shared/utils/package-analysis.ts index f5ce079725..f46c98518d 100644 --- a/shared/utils/package-analysis.ts +++ b/shared/utils/package-analysis.ts @@ -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 = { + '.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): 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, ): TypesStatus { // Check for built-in types if (pkg.types || pkg.typings) { @@ -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 { @@ -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 } /** @@ -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, diff --git a/test/unit/shared/utils/package-analysis.spec.ts b/test/unit/shared/utils/package-analysis.spec.ts index af2cae0401..1508559f00 100644 --- a/test/unit/shared/utils/package-analysis.spec.ts +++ b/test/unit/shared/utils/package-analysis.spec.ts @@ -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', () => {