Skip to content

Commit 60833c7

Browse files
feat(package): add health score widget powered by npm Pulse
Adds a new PackageHealthScore component to the package sidebar that displays an overall health score (0-100) with letter grade (A-F) and four dimension bars: Maintenance (30%), Quality (25%), Security (25%), and Popularity (20%). Data is fetched client-side from https://npm-pulse.vercel.app/api/v1/score/{package} and rendered lazily to avoid blocking page load. - app/components/Package/HealthScore.vue: new sidebar widget - app/pages/package/[[org]]/[name].vue: inject component above download stats - i18n/locales/en.json: add package.health_score translation keys - i18n/locales/ar-EG.json: add Arabic (Egyptian) translations - i18n/locales/fr-FR.json: add French translations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8972153 commit 60833c7

File tree

5 files changed

+3665
-3460
lines changed

5 files changed

+3665
-3460
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<script setup lang="ts">
2+
interface HealthScoreDimension {
3+
score: number
4+
weight: number
5+
}
6+
7+
interface HealthScoreResponse {
8+
package: string
9+
version: string
10+
score: number
11+
grade: 'A' | 'B' | 'C' | 'D' | 'F'
12+
dimensions: {
13+
maintenance: HealthScoreDimension
14+
quality: HealthScoreDimension
15+
security: HealthScoreDimension
16+
popularity: HealthScoreDimension
17+
}
18+
analyzedAt: string
19+
}
20+
21+
const props = defineProps<{
22+
packageName: string
23+
version?: string
24+
}>()
25+
26+
const { data, status } = useFetch<HealthScoreResponse>(
27+
() => {
28+
const base = `https://npm-pulse.vercel.app/api/v1/score/${props.packageName}`
29+
return props.version ? `${base}?version=${props.version}` : base
30+
},
31+
{
32+
key: () => `health-score-${props.packageName}-${props.version ?? 'latest'}`,
33+
server: false,
34+
lazy: true,
35+
},
36+
)
37+
38+
const isLoading = computed(() => status.value === 'pending' || status.value === 'idle')
39+
const isError = computed(() => status.value === 'error')
40+
41+
function gradeColor(grade: string | undefined): string {
42+
switch (grade) {
43+
case 'A': return 'text-emerald-500'
44+
case 'B': return 'text-lime-500'
45+
case 'C': return 'text-amber-500'
46+
case 'D': return 'text-orange-500'
47+
case 'F': return 'text-red-500'
48+
default: return 'text-fg-subtle'
49+
}
50+
}
51+
52+
function scoreBarColor(score: number): string {
53+
if (score >= 80) return 'bg-emerald-500'
54+
if (score >= 60) return 'bg-lime-500'
55+
if (score >= 40) return 'bg-amber-500'
56+
if (score >= 20) return 'bg-orange-500'
57+
return 'bg-red-500'
58+
}
59+
60+
const dimensions = computed(() => {
61+
if (!data.value) return []
62+
const d = data.value.dimensions
63+
return [
64+
{ key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: d.maintenance.score, weight: d.maintenance.weight },
65+
{ key: 'quality', label: $t('package.health_score.dimension_quality'), score: d.quality.score, weight: d.quality.weight },
66+
{ key: 'security', label: $t('package.health_score.dimension_security'), score: d.security.score, weight: d.security.weight },
67+
{ key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: d.popularity.score, weight: d.popularity.weight },
68+
]
69+
})
70+
</script>
71+
72+
<template>
73+
<section aria-labelledby="health-score-heading">
74+
<h2
75+
id="health-score-heading"
76+
class="text-xs text-fg-subtle uppercase tracking-wider mb-3 flex items-center gap-1.5"
77+
>
78+
<span class="i-lucide:activity w-3.5 h-3.5" aria-hidden="true" />
79+
{{ $t('package.health_score.title') }}
80+
</h2>
81+
82+
<!-- Loading state -->
83+
<div v-if="isLoading" class="flex items-center gap-2 text-fg-subtle text-sm">
84+
<span class="i-svg-spinners:ring-resize w-4 h-4" aria-hidden="true" />
85+
<span>{{ $t('package.health_score.loading') }}</span>
86+
</div>
87+
88+
<!-- Error state -->
89+
<div v-else-if="isError" class="flex items-center gap-2 text-fg-subtle text-sm">
90+
<span class="i-lucide:circle-alert w-4 h-4" aria-hidden="true" />
91+
<span>{{ $t('package.health_score.error') }}</span>
92+
</div>
93+
94+
<!-- Score display -->
95+
<div v-else-if="data" class="space-y-3">
96+
<!-- Score header: large score + grade badge -->
97+
<div class="flex items-center gap-3">
98+
<TooltipApp :text="$t('package.health_score.score_tooltip')" strategy="fixed">
99+
<div class="flex items-baseline gap-1 cursor-default" tabindex="0">
100+
<span class="font-mono text-2xl font-bold text-fg leading-none">{{ data.score }}</span>
101+
<span class="text-xs text-fg-subtle">/100</span>
102+
</div>
103+
</TooltipApp>
104+
105+
<TooltipApp
106+
:text="$t('package.health_score.grade_tooltip', { grade: data.grade })"
107+
strategy="fixed"
108+
>
109+
<TagStatic
110+
tabindex="0"
111+
:class="gradeColor(data.grade)"
112+
class="font-mono font-bold text-sm! min-w-8 justify-center"
113+
variant="ghost"
114+
>
115+
{{ data.grade }}
116+
</TagStatic>
117+
</TooltipApp>
118+
</div>
119+
120+
<!-- Dimension bars -->
121+
<ul
122+
class="space-y-2 list-none m-0 p-0"
123+
:aria-label="$t('package.health_score.dimensions_label')"
124+
>
125+
<li v-for="dim in dimensions" :key="dim.key">
126+
<div class="flex items-center justify-between mb-0.5">
127+
<span class="text-xs text-fg-subtle">{{ dim.label }}</span>
128+
<span class="font-mono text-xs text-fg-muted">{{ dim.score }}</span>
129+
</div>
130+
<div
131+
class="h-1.5 w-full rounded-full overflow-hidden"
132+
style="background-color: var(--border)"
133+
role="progressbar"
134+
:aria-valuenow="dim.score"
135+
aria-valuemin="0"
136+
aria-valuemax="100"
137+
:aria-label="`${dim.label}: ${dim.score}/100`"
138+
>
139+
<div
140+
class="h-full rounded-full transition-all duration-500"
141+
:class="scoreBarColor(dim.score)"
142+
:style="{ width: `${dim.score}%` }"
143+
/>
144+
</div>
145+
</li>
146+
</ul>
147+
148+
<!-- Footer link -->
149+
<a
150+
:href="`https://npm-pulse.vercel.app/api/v1/score/${packageName}`"
151+
target="_blank"
152+
rel="noopener noreferrer"
153+
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors duration-150 underline underline-offset-2 decoration-fg-subtle/40"
154+
>
155+
{{ $t('package.health_score.powered_by') }}
156+
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
157+
</a>
158+
</div>
159+
</section>
160+
</template>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script setup lang="ts">
1+
<script setup lang="ts">
22
import { assertValidPackageName } from '#shared/utils/npm'
33
import { getDependencyCount } from '~/utils/npm/dependency-count'
44
@@ -926,6 +926,12 @@ const showSkeleton = shallowRef(false)
926926
</template>
927927
</ClientOnly>
928928

929+
<!-- Health Score (npm Pulse) -->
930+
<PackageHealthScore
931+
:package-name="pkg.name"
932+
:version="resolvedVersion || undefined"
933+
/>
934+
929935
<!-- Download stats -->
930936
<PackageWeeklyDownloadStats
931937
:packageName

0 commit comments

Comments
 (0)