Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions app/components/Package/HealthScore.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<script setup lang="ts">
interface HealthScoreDimension {
score: number
weight: number
}

interface HealthScoreResponse {
package: string
version: string
score: number
grade: 'A' | 'B' | 'C' | 'D' | 'F'
dimensions: {
maintenance: HealthScoreDimension
quality: HealthScoreDimension
security: HealthScoreDimension
popularity: HealthScoreDimension
}
analyzedAt: string
}

const props = defineProps<{
packageName: string
version?: string
}>()

const { data, status } = useFetch<HealthScoreResponse>(
() => {
const base = `https://npm-pulse.vercel.app/api/v1/score/${props.packageName}`
return props.version ? `${base}?version=${props.version}` : base
},
{
key: () => `health-score-${props.packageName}-${props.version ?? 'latest'}`,
server: false,
lazy: true,
},
)

const isLoading = computed(() => status.value === 'pending' || status.value === 'idle')
const isError = computed(() => status.value === 'error')

function gradeColor(grade: string | undefined): string {
switch (grade) {
case 'A':
return 'text-emerald-500'
case 'B':
return 'text-lime-500'
case 'C':
return 'text-amber-500'
case 'D':
return 'text-orange-500'
case 'F':
return 'text-red-500'
default:
return 'text-fg-subtle'
}
}

function scoreBarColor(score: number): string {
if (score >= 80) return 'bg-emerald-500'
if (score >= 60) return 'bg-lime-500'
if (score >= 40) return 'bg-amber-500'
if (score >= 20) return 'bg-orange-500'
return 'bg-red-500'
}

const dimensions = computed(() => {
if (!data.value?.dimensions) return []
const d = data.value.dimensions
return [
{
key: 'maintenance',
label: $t('package.health_score.dimension_maintenance'),
score: d.maintenance?.score ?? 0,
weight: d.maintenance?.weight ?? 0,
},
{
key: 'quality',
label: $t('package.health_score.dimension_quality'),
score: d.quality?.score ?? 0,
weight: d.quality?.weight ?? 0,
},
{
key: 'security',
label: $t('package.health_score.dimension_security'),
score: d.security?.score ?? 0,
weight: d.security?.weight ?? 0,
},
{
key: 'popularity',
label: $t('package.health_score.dimension_popularity'),
score: d.popularity?.score ?? 0,
weight: d.popularity?.weight ?? 0,
},
]
Comment on lines +66 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp score values to 0–100 before binding to progress UI.

Scores from a remote API are trusted as-is. If a value is out of range, aria-valuenow and width can become invalid (>100 or <0), causing accessibility and rendering issues.

Suggested fix
+function clampScore(score: number): number {
+  if (!Number.isFinite(score))
+    return 0
+  return Math.max(0, Math.min(100, score))
+}
+
 const dimensions = computed(() => {
   if (!data.value?.dimensions) return []
   const d = data.value.dimensions
   return [
-    { key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: d.maintenance?.score ?? 0, weight: d.maintenance?.weight ?? 0 },
-    { key: 'quality', label: $t('package.health_score.dimension_quality'), score: d.quality?.score ?? 0, weight: d.quality?.weight ?? 0 },
-    { key: 'security', label: $t('package.health_score.dimension_security'), score: d.security?.score ?? 0, weight: d.security?.weight ?? 0 },
-    { key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: d.popularity?.score ?? 0, weight: d.popularity?.weight ?? 0 },
+    { key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: clampScore(d.maintenance?.score ?? 0), weight: d.maintenance?.weight ?? 0 },
+    { key: 'quality', label: $t('package.health_score.dimension_quality'), score: clampScore(d.quality?.score ?? 0), weight: d.quality?.weight ?? 0 },
+    { key: 'security', label: $t('package.health_score.dimension_security'), score: clampScore(d.security?.score ?? 0), weight: d.security?.weight ?? 0 },
+    { key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: clampScore(d.popularity?.score ?? 0), weight: d.popularity?.weight ?? 0 },
   ]
 })

Also applies to: 134-143

})
</script>

<template>
<CollapsibleSection
:title="$t('package.health_score.title')"
:subtitle="$t('package.health_score.algorithm_subtitle')"
:is-loading="isLoading"
icon="i-lucide:activity"
id="health-score"
>
<!-- Error state -->
<div v-if="isError" class="flex items-center gap-2 text-fg-subtle text-sm">
<span class="i-lucide:circle-alert w-4 h-4" aria-hidden="true" />
<span>{{ $t('package.health_score.error') }}</span>
</div>

<!-- Score display -->
<div v-else-if="data" class="space-y-3">
<!-- Score + grade -->
<div class="flex items-center gap-3">
<TooltipApp :text="$t('package.health_score.score_tooltip')" strategy="fixed">
<div class="flex items-baseline gap-1 cursor-default" tabindex="0">
<span class="font-mono text-2xl font-bold text-fg leading-none">{{ data.score }}</span>
<span class="text-xs text-fg-subtle">/100</span>
</div>
</TooltipApp>

<TooltipApp
:text="$t('package.health_score.grade_tooltip', { grade: data.grade })"
strategy="fixed"
>
<TagStatic
tabindex="0"
:class="gradeColor(data.grade)"
class="font-mono font-bold text-sm! min-w-8 justify-center"
variant="ghost"
>
{{ data.grade }}
</TagStatic>
</TooltipApp>
</div>

<!-- Dimension bars -->
<ul
class="space-y-2 list-none m-0 p-0"
:aria-label="$t('package.health_score.dimensions_label')"
>
<li v-for="dim in dimensions" :key="dim.key">
<div class="flex items-center justify-between mb-0.5">
<span class="text-xs text-fg-subtle">
{{ dim.label }}
<span class="text-fg-muted">({{ dim.weight }}%)</span>
</span>
<span class="font-mono text-xs text-fg-muted">{{ dim.score }}</span>
</div>
<div
class="h-1.5 w-full rounded-full overflow-hidden"
style="background-color: var(--border)"
role="progressbar"
:aria-valuenow="dim.score"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`${dim.label}: ${dim.score}/100`"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor(dim.score)"
:style="{ width: `${dim.score}%` }"
/>
</div>
</li>
</ul>

<!-- Footer: link to npm Pulse (homepage, not raw JSON) -->
<a
href="https://npm-pulse.vercel.app"
target="_blank"
rel="noopener noreferrer"
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"
>
{{ $t('package.health_score.powered_by') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</div>
</CollapsibleSection>
</template>
3 changes: 3 additions & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,9 @@ const showSkeleton = shallowRef(false)
</template>
</ClientOnly>

<!-- Health Score (npm Pulse) -->
<PackageHealthScore :package-name="pkg.name" :version="resolvedVersion || undefined" />

<!-- Download stats -->
<PackageWeeklyDownloadStats
:packageName
Expand Down
13 changes: 13 additions & 0 deletions i18n/locales/ar-EG.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,19 @@
"download": {
"button": "تحميل",
"tarball": "تحميل ملف Tarball بصيغة .tar.gz"
},
"health_score": {
"title": "درجة الصحة",
"error": "تعذّر تحميل درجة الصحة",
"score_tooltip": "درجة صحة الحزمة الإجمالية (0-100) مدعومة من npm Pulse",
"grade_tooltip": "تقدير الصحة: {grade}",
"dimensions_label": "أبعاد درجة الصحة",
"dimension_maintenance": "الصيانة",
"dimension_quality": "الجودة",
"dimension_security": "الأمان",
"dimension_popularity": "الانتشار",
"powered_by": "npm Pulse",
"algorithm_subtitle": "صيانة×30% · جودة×25% · أمان×25% · انتشار×20%"
}
},
"claim": {
Expand Down
13 changes: 13 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,19 @@
"download": {
"button": "Download",
"tarball": "Download Tarball as .tar.gz"
},
"health_score": {
"dimensions_label": "Health score dimensions",
"grade_tooltip": "Health grade: {grade}",
"dimension_maintenance": "Maintenance",
"powered_by": "npm Pulse",
"score_tooltip": "Overall package health score (0-100) powered by npm Pulse",
"error": "Could not load health score",
"dimension_security": "Security",
"dimension_popularity": "Popularity",
"title": "Health Score",
"dimension_quality": "Quality",
"algorithm_subtitle": "Maintenance×30% · Quality×25% · Security×25% · Popularity×20%"
}
},
"connector": {
Expand Down
13 changes: 13 additions & 0 deletions i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,19 @@
"download": {
"button": "Télécharger",
"tarball": "Télécharger le tarball au format .tar.gz"
},
"health_score": {
"title": "Score de santé",
"error": "Impossible de charger le score de santé",
"score_tooltip": "Score de santé global du paquet (0-100) propulsé par npm Pulse",
"grade_tooltip": "Note de santé : {grade}",
"dimensions_label": "Dimensions du score de santé",
"dimension_maintenance": "Maintenance",
"dimension_quality": "Qualité",
"dimension_security": "Sécurité",
"dimension_popularity": "Popularité",
"powered_by": "npm Pulse",
"algorithm_subtitle": "Maintenance×30% · Qualité×25% · Sécurité×25% · Popularité×20%"
}
},
"connector": {
Expand Down
39 changes: 39 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,45 @@
}
},
"additionalProperties": false
},
"health_score": {
"type": "object",
"properties": {
"dimensions_label": {
"type": "string"
},
"grade_tooltip": {
"type": "string"
},
"dimension_maintenance": {
"type": "string"
},
"powered_by": {
"type": "string"
},
"score_tooltip": {
"type": "string"
},
"error": {
"type": "string"
},
"dimension_security": {
"type": "string"
},
"dimension_popularity": {
"type": "string"
},
"title": {
"type": "string"
},
"dimension_quality": {
"type": "string"
},
"algorithm_subtitle": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
1 change: 1 addition & 0 deletions modules/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default defineNuxtModule({
'https://registry.npmjs.org',
'https://api.npmjs.org',
'https://npm.antfu.dev',
'https://npm-pulse.vercel.app',
BLUESKY_API,
...ALL_KNOWN_GIT_API_ORIGINS,
// Local CLI connector (npmx CLI communicates via localhost)
Expand Down
11 changes: 11 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ import {
PackageDeprecatedTree,
PackageHeader,
PackageInstallScripts,
PackageHealthScore,
PackageKeywords,
PackageList,
PackageListControls,
Expand Down Expand Up @@ -1227,6 +1228,16 @@ describe('component accessibility audits', () => {
})
})

describe('PackageHealthScore', () => {
it('should have no accessibility violations in loading state', async () => {
const component = await mountSuspended(PackageHealthScore, {
props: { packageName: 'vue' },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})

describe('PackageKeywords', () => {
it('should have no accessibility violations without keywords', async () => {
const component = await mountSuspended(PackageKeywords, {
Expand Down
Loading