forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1/**
2 * Package analysis utilities for detecting module format and TypeScript support
3 */
4
5export type ModuleFormat = 'esm' | 'cjs' | 'dual' | 'unknown'
6
7export type TypesStatus =
8 | { kind: 'included' }
9 | { kind: '@types'; packageName: string; deprecated?: string }
10 | { kind: 'none' }
11
12export interface PackageAnalysis {
13 moduleFormat: ModuleFormat
14 types: TypesStatus
15 engines?: Record<string, string>
16 /** Associated create-* package if it exists */
17 createPackage?: CreatePackageInfo
18}
19
20/**
21 * Extended package.json fields not in @npm/types
22 * These are commonly used but not included in the official types
23 */
24export interface ExtendedPackageJson {
25 name?: string
26 version?: string
27 type?: 'module' | 'commonjs'
28 main?: string
29 module?: string
30 types?: string
31 typings?: string
32 exports?: PackageExports
33 engines?: Record<string, string>
34 dependencies?: Record<string, string>
35 devDependencies?: Record<string, string>
36 peerDependencies?: Record<string, string>
37 /** npm maintainers (returned by registry API) */
38 maintainers?: Array<{ name: string; email?: string }>
39 /** Repository info (returned by registry API) */
40 repository?: { url?: string; type?: string; directory?: string }
41}
42
43export type PackageExports = string | null | { [key: string]: PackageExports } | PackageExports[]
44
45/**
46 * Detect the module format of a package based on package.json fields
47 */
48export function detectModuleFormat(pkg: ExtendedPackageJson): ModuleFormat {
49 const hasExports = pkg.exports != null
50 const hasModule = !!pkg.module
51 const hasMain = !!pkg.main
52 const isTypeModule = pkg.type === 'module'
53 const isTypeCommonjs = pkg.type === 'commonjs' || !pkg.type
54
55 // Check exports field for dual format indicators
56 if (hasExports && pkg.exports) {
57 const exportInfo = analyzeExports(pkg.exports)
58
59 if (exportInfo.hasImport && exportInfo.hasRequire) {
60 return 'dual'
61 }
62
63 if (exportInfo.hasImport || exportInfo.hasModule) {
64 // Has ESM exports, check if also has CJS
65 if (hasMain && !isTypeModule) {
66 return 'dual'
67 }
68 return 'esm'
69 }
70
71 if (exportInfo.hasRequire) {
72 // Has CJS exports, check if also has ESM
73 if (hasModule) {
74 return 'dual'
75 }
76 return 'cjs'
77 }
78
79 // exports field exists but doesn't use import/require conditions
80 // Fall through to other detection methods
81 }
82
83 // Legacy detection without exports field
84 if (hasModule && hasMain) {
85 // Has both module (ESM) and main (CJS) fields
86 return 'dual'
87 }
88
89 if (hasModule || isTypeModule) {
90 return 'esm'
91 }
92
93 if (hasMain || isTypeCommonjs) {
94 return 'cjs'
95 }
96
97 return 'unknown'
98}
99
100interface ExportsAnalysis {
101 hasImport: boolean
102 hasRequire: boolean
103 hasModule: boolean
104 hasTypes: boolean
105}
106
107/**
108 * Recursively analyze exports field for module format indicators
109 */
110function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis {
111 const result: ExportsAnalysis = {
112 hasImport: false,
113 hasRequire: false,
114 hasModule: false,
115 hasTypes: false,
116 }
117
118 // Prevent infinite recursion
119 if (depth > 10) return result
120
121 if (exports === null || exports === undefined) {
122 return result
123 }
124
125 if (typeof exports === 'string') {
126 // Check file extension for format hints
127 if (exports.endsWith('.mjs') || exports.endsWith('.mts')) {
128 result.hasImport = true
129 } else if (exports.endsWith('.cjs') || exports.endsWith('.cts')) {
130 result.hasRequire = true
131 }
132 if (exports.endsWith('.d.ts') || exports.endsWith('.d.mts') || exports.endsWith('.d.cts')) {
133 result.hasTypes = true
134 }
135 return result
136 }
137
138 if (Array.isArray(exports)) {
139 for (const item of exports) {
140 const subResult = analyzeExports(item, depth + 1)
141 mergeExportsAnalysis(result, subResult)
142 }
143 return result
144 }
145
146 if (typeof exports === 'object') {
147 for (const [key, value] of Object.entries(exports)) {
148 // Check condition keys
149 if (key === 'import') {
150 result.hasImport = true
151 } else if (key === 'require') {
152 result.hasRequire = true
153 } else if (key === 'module') {
154 result.hasModule = true
155 } else if (key === 'types') {
156 result.hasTypes = true
157 }
158
159 // Recurse into nested exports
160 const subResult = analyzeExports(value, depth + 1)
161 mergeExportsAnalysis(result, subResult)
162 }
163 }
164
165 return result
166}
167
168function mergeExportsAnalysis(target: ExportsAnalysis, source: ExportsAnalysis): void {
169 target.hasImport = target.hasImport || source.hasImport
170 target.hasRequire = target.hasRequire || source.hasRequire
171 target.hasModule = target.hasModule || source.hasModule
172 target.hasTypes = target.hasTypes || source.hasTypes
173}
174
175/** Info about a related package (@types or create-*) */
176export interface RelatedPackageInfo {
177 packageName: string
178 deprecated?: string
179}
180
181export type TypesPackageInfo = RelatedPackageInfo
182export type CreatePackageInfo = RelatedPackageInfo
183
184/**
185 * Get the create-* package name for a given package.
186 * e.g., "vite" -> "create-vite", "@scope/foo" -> "@scope/create-foo"
187 */
188export function getCreatePackageName(packageName: string): string {
189 if (packageName.startsWith('@')) {
190 // Scoped package: @scope/name -> @scope/create-name
191 const slashIndex = packageName.indexOf('/')
192 const scope = packageName.slice(0, slashIndex)
193 const name = packageName.slice(slashIndex + 1)
194 return `${scope}/create-${name}`
195 }
196 return `create-${packageName}`
197}
198
199/**
200 * Extract the short name from a create-* package for display.
201 * e.g., "create-vite" -> "vite", "@scope/create-foo" -> "foo"
202 */
203export function getCreateShortName(createPackageName: string): string {
204 if (createPackageName.startsWith('@')) {
205 // @scope/create-foo -> foo
206 const slashIndex = createPackageName.indexOf('/')
207 const name = createPackageName.slice(slashIndex + 1)
208 if (name.startsWith('create-')) {
209 return name.slice('create-'.length)
210 }
211 return name
212 }
213 // create-vite -> vite
214 if (createPackageName.startsWith('create-')) {
215 return createPackageName.slice('create-'.length)
216 }
217 return createPackageName
218}
219
220/**
221 * Detect TypeScript types status for a package
222 */
223export function detectTypesStatus(
224 pkg: ExtendedPackageJson,
225 typesPackageInfo?: TypesPackageInfo,
226): TypesStatus {
227 // Check for built-in types
228 if (pkg.types || pkg.typings) {
229 return { kind: 'included' }
230 }
231
232 // Check exports field for types
233 if (pkg.exports) {
234 const exportInfo = analyzeExports(pkg.exports)
235 if (exportInfo.hasTypes) {
236 return { kind: 'included' }
237 }
238 }
239
240 // Check for @types package
241 if (typesPackageInfo) {
242 return {
243 kind: '@types',
244 packageName: typesPackageInfo.packageName,
245 deprecated: typesPackageInfo.deprecated,
246 }
247 }
248
249 return { kind: 'none' }
250}
251
252/**
253 * Check if a package has built-in TypeScript types
254 * (without needing to check for @types packages)
255 */
256export function hasBuiltInTypes(pkg: ExtendedPackageJson): boolean {
257 // Check types/typings field
258 if (pkg.types || pkg.typings) {
259 return true
260 }
261
262 // Check exports field for types
263 if (pkg.exports) {
264 const exportInfo = analyzeExports(pkg.exports)
265 if (exportInfo.hasTypes) {
266 return true
267 }
268 }
269
270 return false
271}
272
273/**
274 * Get the @types package name for a given package
275 */
276export function getTypesPackageName(packageName: string): string {
277 if (packageName.startsWith('@')) {
278 // Scoped package: @scope/name -> @types/scope__name
279 return `@types/${packageName.slice(1).replace('/', '__')}`
280 }
281 return `@types/${packageName}`
282}
283
284/**
285 * Options for package analysis
286 */
287export interface AnalyzePackageOptions {
288 typesPackage?: TypesPackageInfo
289 createPackage?: CreatePackageInfo
290}
291
292/**
293 * Analyze a package and return structured analysis
294 */
295export function analyzePackage(
296 pkg: ExtendedPackageJson,
297 options?: AnalyzePackageOptions,
298): PackageAnalysis {
299 const moduleFormat = detectModuleFormat(pkg)
300
301 const types = detectTypesStatus(pkg, options?.typesPackage)
302
303 return {
304 moduleFormat,
305 types,
306 engines: pkg.engines,
307 createPackage: options?.createPackage,
308 }
309}