[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: display ts and cjs/esm badges

+692
+115
app/components/PackageMetricsBadges.vue
··· 1 + <script setup lang="ts"> 2 + import type { ModuleFormat, TypesStatus } from '#shared/utils/package-analysis' 3 + 4 + const props = defineProps<{ 5 + packageName: string 6 + version?: string 7 + }>() 8 + 9 + interface PackageAnalysisResponse { 10 + package: string 11 + version: string 12 + moduleFormat: ModuleFormat 13 + types: TypesStatus 14 + engines?: { 15 + node?: string 16 + npm?: string 17 + } 18 + } 19 + 20 + const { data: analysis, status } = useLazyFetch<PackageAnalysisResponse>( 21 + () => { 22 + const base = `/api/registry/analysis/${props.packageName}` 23 + return props.version ? `${base}/v/${props.version}` : base 24 + }, 25 + { 26 + server: false, // Client-side only to avoid blocking initial render 27 + }, 28 + ) 29 + 30 + const moduleFormatLabel = computed(() => { 31 + if (!analysis.value) return null 32 + switch (analysis.value.moduleFormat) { 33 + case 'esm': 34 + return 'ESM' 35 + case 'cjs': 36 + return 'CJS' 37 + case 'dual': 38 + return 'CJS/ESM' 39 + default: 40 + return null 41 + } 42 + }) 43 + 44 + const moduleFormatTooltip = computed(() => { 45 + if (!analysis.value) return '' 46 + switch (analysis.value.moduleFormat) { 47 + case 'esm': 48 + return 'ES Modules only' 49 + case 'cjs': 50 + return 'CommonJS only' 51 + case 'dual': 52 + return 'Supports both CommonJS and ES Modules' 53 + default: 54 + return 'Unknown module format' 55 + } 56 + }) 57 + 58 + const hasTypes = computed(() => { 59 + if (!analysis.value) return false 60 + return analysis.value.types.kind === 'included' || analysis.value.types.kind === '@types' 61 + }) 62 + 63 + const typesTooltip = computed(() => { 64 + if (!analysis.value) return '' 65 + switch (analysis.value.types.kind) { 66 + case 'included': 67 + return 'TypeScript types included' 68 + case '@types': 69 + return `Types from ${analysis.value.types.packageName}` 70 + default: 71 + return '' 72 + } 73 + }) 74 + 75 + const typesHref = computed(() => { 76 + if (!analysis.value) return null 77 + if (analysis.value.types.kind === '@types') { 78 + return `/${analysis.value.types.packageName}` 79 + } 80 + return null 81 + }) 82 + </script> 83 + 84 + <template> 85 + <!-- Loading skeleton --> 86 + <div v-if="status === 'pending'" class="flex items-center gap-1.5"> 87 + <span class="skeleton w-8 h-5 rounded" /> 88 + <span class="skeleton w-12 h-5 rounded" /> 89 + </div> 90 + 91 + <ul v-else-if="analysis" class="flex items-center gap-1.5 list-none m-0 p-0"> 92 + <!-- TypeScript types --> 93 + <li v-if="hasTypes"> 94 + <component 95 + :is="typesHref ? 'NuxtLink' : 'span'" 96 + :to="typesHref" 97 + class="inline-flex items-center px-1.5 py-0.5 font-mono text-xs text-fg-muted bg-bg-muted border border-border rounded transition-colors duration-200" 98 + :class="typesHref ? 'hover:text-fg hover:border-border-hover' : ''" 99 + :title="typesTooltip" 100 + > 101 + TS 102 + </component> 103 + </li> 104 + 105 + <!-- Module format --> 106 + <li v-if="moduleFormatLabel"> 107 + <span 108 + class="inline-flex items-center px-1.5 py-0.5 font-mono text-xs text-fg-muted bg-bg-muted border border-border rounded transition-colors duration-200" 109 + :title="moduleFormatTooltip" 110 + > 111 + {{ moduleFormatLabel }} 112 + </span> 113 + </li> 114 + </ul> 115 + </template>
+7
app/pages/[...package].vue
··· 333 333 aria-label="Verified provenance" 334 334 /> 335 335 </a> 336 + 337 + <!-- Package metrics (module format, types) --> 338 + <PackageMetricsBadges 339 + v-if="displayVersion" 340 + :package-name="pkg.name" 341 + :version="displayVersion.version" 342 + /> 336 343 </div> 337 344 <!-- Fixed height description container to prevent CLS --> 338 345 <div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">
+92
server/api/registry/analysis/[...pkg].get.ts
··· 1 + import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis' 2 + import { 3 + analyzePackage, 4 + getTypesPackageName, 5 + hasBuiltInTypes, 6 + } from '#shared/utils/package-analysis' 7 + 8 + const NPM_REGISTRY = 'https://registry.npmjs.org' 9 + 10 + export default defineCachedEventHandler( 11 + async event => { 12 + const pkgParam = getRouterParam(event, 'pkg') 13 + if (!pkgParam) { 14 + throw createError({ statusCode: 400, message: 'Package name is required' }) 15 + } 16 + 17 + // Parse package name and optional version from path 18 + // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0" 19 + const segments = pkgParam.split('/') 20 + let packageName: string 21 + let version: string | undefined 22 + 23 + const vIndex = segments.indexOf('v') 24 + if (vIndex !== -1 && vIndex < segments.length - 1) { 25 + packageName = segments.slice(0, vIndex).join('/') 26 + version = segments.slice(vIndex + 1).join('/') 27 + } else { 28 + packageName = segments.join('/') 29 + } 30 + 31 + try { 32 + // Fetch package data 33 + const encodedName = encodePackageName(packageName) 34 + const versionSuffix = version ? `/${version}` : '/latest' 35 + const pkg = await $fetch<ExtendedPackageJson>( 36 + `${NPM_REGISTRY}/${encodedName}${versionSuffix}`, 37 + ) 38 + 39 + // Only check for @types package if the package doesn't ship its own types 40 + let typesPackageExists = false 41 + if (!hasBuiltInTypes(pkg)) { 42 + const typesPackageName = getTypesPackageName(packageName) 43 + typesPackageExists = await checkPackageExists(typesPackageName) 44 + } 45 + 46 + const analysis = analyzePackage(pkg, { typesPackageExists }) 47 + 48 + return { 49 + package: packageName, 50 + version: pkg.version ?? version ?? 'latest', 51 + ...analysis, 52 + } satisfies PackageAnalysisResponse 53 + } catch (error) { 54 + if (error && typeof error === 'object' && 'statusCode' in error) { 55 + throw error 56 + } 57 + throw createError({ 58 + statusCode: 502, 59 + message: 'Failed to analyze package', 60 + }) 61 + } 62 + }, 63 + { 64 + maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes 65 + swr: true, 66 + getKey: event => getRouterParam(event, 'pkg') ?? '', 67 + }, 68 + ) 69 + 70 + function encodePackageName(name: string): string { 71 + if (name.startsWith('@')) { 72 + return `@${encodeURIComponent(name.slice(1))}` 73 + } 74 + return encodeURIComponent(name) 75 + } 76 + 77 + async function checkPackageExists(packageName: string): Promise<boolean> { 78 + try { 79 + const encodedName = encodePackageName(packageName) 80 + const response = await $fetch.raw(`${NPM_REGISTRY}/${encodedName}`, { 81 + method: 'HEAD', 82 + }) 83 + return response.status === 200 84 + } catch { 85 + return false 86 + } 87 + } 88 + 89 + export interface PackageAnalysisResponse extends PackageAnalysis { 90 + package: string 91 + version: string 92 + }
+254
shared/utils/package-analysis.ts
··· 1 + /** 2 + * Package analysis utilities for detecting module format and TypeScript support 3 + */ 4 + 5 + export type ModuleFormat = 'esm' | 'cjs' | 'dual' | 'unknown' 6 + 7 + export type TypesStatus = 8 + | { kind: 'included' } 9 + | { kind: '@types'; packageName: string } 10 + | { kind: 'none' } 11 + 12 + export interface PackageAnalysis { 13 + moduleFormat: ModuleFormat 14 + types: TypesStatus 15 + engines?: { 16 + node?: string 17 + npm?: string 18 + } 19 + } 20 + 21 + /** 22 + * Extended package.json fields not in @npm/types 23 + * These are commonly used but not included in the official types 24 + */ 25 + export interface ExtendedPackageJson { 26 + name?: string 27 + version?: string 28 + type?: 'module' | 'commonjs' 29 + main?: string 30 + module?: string 31 + types?: string 32 + typings?: string 33 + exports?: PackageExports 34 + engines?: Record<string, string> 35 + dependencies?: Record<string, string> 36 + devDependencies?: Record<string, string> 37 + peerDependencies?: Record<string, string> 38 + } 39 + 40 + export type PackageExports = string | null | { [key: string]: PackageExports } | PackageExports[] 41 + 42 + /** 43 + * Detect the module format of a package based on package.json fields 44 + */ 45 + export function detectModuleFormat(pkg: ExtendedPackageJson): ModuleFormat { 46 + const hasExports = pkg.exports != null 47 + const hasModule = !!pkg.module 48 + const hasMain = !!pkg.main 49 + const isTypeModule = pkg.type === 'module' 50 + const isTypeCommonjs = pkg.type === 'commonjs' || !pkg.type 51 + 52 + // Check exports field for dual format indicators 53 + if (hasExports && pkg.exports) { 54 + const exportInfo = analyzeExports(pkg.exports) 55 + 56 + if (exportInfo.hasImport && exportInfo.hasRequire) { 57 + return 'dual' 58 + } 59 + 60 + if (exportInfo.hasImport || exportInfo.hasModule) { 61 + // Has ESM exports, check if also has CJS 62 + if (hasMain && !isTypeModule) { 63 + return 'dual' 64 + } 65 + return 'esm' 66 + } 67 + 68 + if (exportInfo.hasRequire) { 69 + // Has CJS exports, check if also has ESM 70 + if (hasModule) { 71 + return 'dual' 72 + } 73 + return 'cjs' 74 + } 75 + 76 + // exports field exists but doesn't use import/require conditions 77 + // Fall through to other detection methods 78 + } 79 + 80 + // Legacy detection without exports field 81 + if (hasModule && hasMain) { 82 + // Has both module (ESM) and main (CJS) fields 83 + return 'dual' 84 + } 85 + 86 + if (hasModule || isTypeModule) { 87 + return 'esm' 88 + } 89 + 90 + if (hasMain || isTypeCommonjs) { 91 + return 'cjs' 92 + } 93 + 94 + return 'unknown' 95 + } 96 + 97 + interface ExportsAnalysis { 98 + hasImport: boolean 99 + hasRequire: boolean 100 + hasModule: boolean 101 + hasTypes: boolean 102 + } 103 + 104 + /** 105 + * Recursively analyze exports field for module format indicators 106 + */ 107 + function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis { 108 + const result: ExportsAnalysis = { 109 + hasImport: false, 110 + hasRequire: false, 111 + hasModule: false, 112 + hasTypes: false, 113 + } 114 + 115 + // Prevent infinite recursion 116 + if (depth > 10) return result 117 + 118 + if (exports === null || exports === undefined) { 119 + return result 120 + } 121 + 122 + if (typeof exports === 'string') { 123 + // Check file extension for format hints 124 + if (exports.endsWith('.mjs') || exports.endsWith('.mts')) { 125 + result.hasImport = true 126 + } else if (exports.endsWith('.cjs') || exports.endsWith('.cts')) { 127 + result.hasRequire = true 128 + } 129 + if (exports.endsWith('.d.ts') || exports.endsWith('.d.mts') || exports.endsWith('.d.cts')) { 130 + result.hasTypes = true 131 + } 132 + return result 133 + } 134 + 135 + if (Array.isArray(exports)) { 136 + for (const item of exports) { 137 + const subResult = analyzeExports(item, depth + 1) 138 + mergeExportsAnalysis(result, subResult) 139 + } 140 + return result 141 + } 142 + 143 + if (typeof exports === 'object') { 144 + for (const [key, value] of Object.entries(exports)) { 145 + // Check condition keys 146 + if (key === 'import') { 147 + result.hasImport = true 148 + } else if (key === 'require') { 149 + result.hasRequire = true 150 + } else if (key === 'module') { 151 + result.hasModule = true 152 + } else if (key === 'types') { 153 + result.hasTypes = true 154 + } 155 + 156 + // Recurse into nested exports 157 + const subResult = analyzeExports(value, depth + 1) 158 + mergeExportsAnalysis(result, subResult) 159 + } 160 + } 161 + 162 + return result 163 + } 164 + 165 + function mergeExportsAnalysis(target: ExportsAnalysis, source: ExportsAnalysis): void { 166 + target.hasImport = target.hasImport || source.hasImport 167 + target.hasRequire = target.hasRequire || source.hasRequire 168 + target.hasModule = target.hasModule || source.hasModule 169 + target.hasTypes = target.hasTypes || source.hasTypes 170 + } 171 + 172 + /** 173 + * Detect TypeScript types status for a package 174 + */ 175 + export function detectTypesStatus( 176 + pkg: ExtendedPackageJson, 177 + typesPackageName?: string, 178 + ): TypesStatus { 179 + // Check for built-in types 180 + if (pkg.types || pkg.typings) { 181 + return { kind: 'included' } 182 + } 183 + 184 + // Check exports field for types 185 + if (pkg.exports) { 186 + const exportInfo = analyzeExports(pkg.exports) 187 + if (exportInfo.hasTypes) { 188 + return { kind: 'included' } 189 + } 190 + } 191 + 192 + // Check for @types package 193 + if (typesPackageName) { 194 + return { kind: '@types', packageName: typesPackageName } 195 + } 196 + 197 + return { kind: 'none' } 198 + } 199 + 200 + /** 201 + * Check if a package has built-in TypeScript types 202 + * (without needing to check for @types packages) 203 + */ 204 + export function hasBuiltInTypes(pkg: ExtendedPackageJson): boolean { 205 + // Check types/typings field 206 + if (pkg.types || pkg.typings) { 207 + return true 208 + } 209 + 210 + // Check exports field for types 211 + if (pkg.exports) { 212 + const exportInfo = analyzeExports(pkg.exports) 213 + if (exportInfo.hasTypes) { 214 + return true 215 + } 216 + } 217 + 218 + return false 219 + } 220 + 221 + /** 222 + * Get the @types package name for a given package 223 + */ 224 + export function getTypesPackageName(packageName: string): string { 225 + if (packageName.startsWith('@')) { 226 + // Scoped package: @scope/name -> @types/scope__name 227 + return `@types/${packageName.slice(1).replace('/', '__')}` 228 + } 229 + return `@types/${packageName}` 230 + } 231 + 232 + /** 233 + * Analyze a package and return structured analysis 234 + */ 235 + export function analyzePackage( 236 + pkg: ExtendedPackageJson, 237 + options?: { typesPackageExists?: boolean }, 238 + ): PackageAnalysis { 239 + const moduleFormat = detectModuleFormat(pkg) 240 + 241 + const typesPackageName = pkg.name ? getTypesPackageName(pkg.name) : undefined 242 + const types = detectTypesStatus(pkg, options?.typesPackageExists ? typesPackageName : undefined) 243 + 244 + return { 245 + moduleFormat, 246 + types, 247 + engines: pkg.engines 248 + ? { 249 + node: pkg.engines.node, 250 + npm: pkg.engines.npm, 251 + } 252 + : undefined, 253 + } 254 + }
+224
test/unit/package-analysis.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + analyzePackage, 4 + detectModuleFormat, 5 + detectTypesStatus, 6 + getTypesPackageName, 7 + hasBuiltInTypes, 8 + } from '../../shared/utils/package-analysis' 9 + 10 + describe('detectModuleFormat', () => { 11 + it('detects ESM from type: module', () => { 12 + expect(detectModuleFormat({ type: 'module', main: 'index.js' })).toBe('esm') 13 + }) 14 + 15 + it('detects CJS from type: commonjs', () => { 16 + expect(detectModuleFormat({ type: 'commonjs', main: 'index.js' })).toBe('cjs') 17 + }) 18 + 19 + it('detects CJS when no type field (default)', () => { 20 + expect(detectModuleFormat({ main: 'index.js' })).toBe('cjs') 21 + }) 22 + 23 + it('detects dual from module + main fields', () => { 24 + expect(detectModuleFormat({ module: 'index.mjs', main: 'index.js' })).toBe('dual') 25 + }) 26 + 27 + it('detects ESM from module field without main', () => { 28 + expect(detectModuleFormat({ module: 'index.mjs' })).toBe('esm') 29 + }) 30 + 31 + it('detects dual from exports with import + require conditions', () => { 32 + expect( 33 + detectModuleFormat({ 34 + exports: { 35 + '.': { 36 + import: './index.mjs', 37 + require: './index.cjs', 38 + }, 39 + }, 40 + }), 41 + ).toBe('dual') 42 + }) 43 + 44 + it('detects ESM from exports with only import condition', () => { 45 + expect( 46 + detectModuleFormat({ 47 + type: 'module', 48 + exports: { 49 + '.': { 50 + import: './index.js', 51 + }, 52 + }, 53 + }), 54 + ).toBe('esm') 55 + }) 56 + 57 + it('detects CJS from exports with only require condition', () => { 58 + expect( 59 + detectModuleFormat({ 60 + exports: { 61 + '.': { 62 + require: './index.cjs', 63 + }, 64 + }, 65 + }), 66 + ).toBe('cjs') 67 + }) 68 + 69 + it('detects dual from nested exports with both conditions', () => { 70 + expect( 71 + detectModuleFormat({ 72 + exports: { 73 + '.': { 74 + import: { 75 + types: './dist/index.d.mts', 76 + default: './dist/index.mjs', 77 + }, 78 + require: { 79 + types: './dist/index.d.ts', 80 + default: './dist/index.cjs', 81 + }, 82 + }, 83 + }, 84 + }), 85 + ).toBe('dual') 86 + }) 87 + 88 + it('returns cjs for empty package (npm default)', () => { 89 + // npm treats packages without type field as CommonJS 90 + expect(detectModuleFormat({})).toBe('cjs') 91 + }) 92 + }) 93 + 94 + describe('detectTypesStatus', () => { 95 + it('detects included types from types field', () => { 96 + expect(detectTypesStatus({ types: './index.d.ts' })).toEqual({ kind: 'included' }) 97 + }) 98 + 99 + it('detects included types from typings field', () => { 100 + expect(detectTypesStatus({ typings: './index.d.ts' })).toEqual({ kind: 'included' }) 101 + }) 102 + 103 + it('detects included types from exports with types condition', () => { 104 + expect( 105 + detectTypesStatus({ 106 + exports: { 107 + '.': { 108 + types: './index.d.ts', 109 + default: './index.js', 110 + }, 111 + }, 112 + }), 113 + ).toEqual({ kind: 'included' }) 114 + }) 115 + 116 + it('detects @types package when provided', () => { 117 + expect(detectTypesStatus({}, '@types/lodash')).toEqual({ 118 + kind: '@types', 119 + packageName: '@types/lodash', 120 + }) 121 + }) 122 + 123 + it('returns none when no types detected', () => { 124 + expect(detectTypesStatus({})).toEqual({ kind: 'none' }) 125 + }) 126 + }) 127 + 128 + describe('getTypesPackageName', () => { 129 + it('handles unscoped package', () => { 130 + expect(getTypesPackageName('lodash')).toBe('@types/lodash') 131 + }) 132 + 133 + it('handles scoped package', () => { 134 + expect(getTypesPackageName('@nuxt/kit')).toBe('@types/nuxt__kit') 135 + }) 136 + }) 137 + 138 + describe('hasBuiltInTypes', () => { 139 + it('returns true when types field is present', () => { 140 + expect(hasBuiltInTypes({ types: './index.d.ts' })).toBe(true) 141 + }) 142 + 143 + it('returns true when typings field is present', () => { 144 + expect(hasBuiltInTypes({ typings: './index.d.ts' })).toBe(true) 145 + }) 146 + 147 + it('returns true when exports has types condition', () => { 148 + expect( 149 + hasBuiltInTypes({ 150 + exports: { 151 + '.': { 152 + types: './index.d.ts', 153 + default: './index.js', 154 + }, 155 + }, 156 + }), 157 + ).toBe(true) 158 + }) 159 + 160 + it('returns false when no types are present', () => { 161 + expect(hasBuiltInTypes({ main: 'index.js' })).toBe(false) 162 + }) 163 + 164 + it('returns false for empty package', () => { 165 + expect(hasBuiltInTypes({})).toBe(false) 166 + }) 167 + }) 168 + 169 + describe('analyzePackage', () => { 170 + it('analyzes Vue package correctly', () => { 171 + const result = analyzePackage({ 172 + name: 'vue', 173 + type: undefined, 174 + main: 'index.js', 175 + module: 'dist/vue.runtime.esm-bundler.js', 176 + types: 'dist/vue.d.ts', 177 + exports: { 178 + '.': { 179 + import: './dist/vue.runtime.esm-bundler.js', 180 + require: './index.js', 181 + }, 182 + }, 183 + }) 184 + 185 + expect(result.moduleFormat).toBe('dual') 186 + expect(result.types).toEqual({ kind: 'included' }) 187 + }) 188 + 189 + it('analyzes ESM-only package correctly', () => { 190 + const result = analyzePackage({ 191 + name: 'execa', 192 + type: 'module', 193 + exports: { 194 + types: './index.d.ts', 195 + default: './index.js', 196 + }, 197 + }) 198 + 199 + expect(result.moduleFormat).toBe('esm') 200 + expect(result.types).toEqual({ kind: 'included' }) 201 + }) 202 + 203 + it('includes engines when present', () => { 204 + const result = analyzePackage({ 205 + name: 'test', 206 + main: 'index.js', 207 + engines: { 208 + node: '>=18', 209 + npm: '>=9', 210 + }, 211 + }) 212 + 213 + expect(result.engines).toEqual({ node: '>=18', npm: '>=9' }) 214 + }) 215 + 216 + it('detects @types package when typesPackageExists is true', () => { 217 + const result = analyzePackage( 218 + { name: 'express', main: 'index.js' }, 219 + { typesPackageExists: true }, 220 + ) 221 + 222 + expect(result.types).toEqual({ kind: '@types', packageName: '@types/express' }) 223 + }) 224 + })