Skip to content

Commit 12ee5e0

Browse files
committed
feat: display ts and cjs/esm badges
1 parent f77c5ff commit 12ee5e0

5 files changed

Lines changed: 692 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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>

app/pages/[...package].vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,13 @@ defineOgImageComponent('Package', {
333333
aria-label="Verified provenance"
334334
/>
335335
</a>
336+
337+
<!-- Package metrics (module format, types) -->
338+
<PackageMetricsBadges
339+
v-if="displayVersion"
340+
:package-name="pkg.name"
341+
:version="displayVersion.version"
342+
/>
336343
</div>
337344
<!-- Fixed height description container to prevent CLS -->
338345
<div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
}

0 commit comments

Comments
 (0)