From cfa8db000fdc419d3dc80ce11038e241966724b8 Mon Sep 17 00:00:00 2001 From: "abderrahmen.mhemed" Date: Wed, 4 Feb 2026 09:50:30 +0100 Subject: [PATCH 1/6] feat: added npm package scores --- app/components/Package/ScoreGauges.vue | 66 +++++++++++++++++++++++ app/composables/npm/usePackageScore.ts | 5 ++ app/pages/package/[...package].vue | 3 ++ i18n/locales/en.json | 8 +++ lunaria/files/en-GB.json | 8 +++ lunaria/files/en-US.json | 8 +++ server/api/registry/score/[...pkg].get.ts | 49 +++++++++++++++++ 7 files changed, 147 insertions(+) create mode 100644 app/components/Package/ScoreGauges.vue create mode 100644 app/composables/npm/usePackageScore.ts create mode 100644 server/api/registry/score/[...pkg].get.ts diff --git a/app/components/Package/ScoreGauges.vue b/app/components/Package/ScoreGauges.vue new file mode 100644 index 000000000..613cd38ba --- /dev/null +++ b/app/components/Package/ScoreGauges.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/composables/npm/usePackageScore.ts b/app/composables/npm/usePackageScore.ts new file mode 100644 index 000000000..9e2b9a49f --- /dev/null +++ b/app/composables/npm/usePackageScore.ts @@ -0,0 +1,5 @@ +import type { NpmsScore } from '#server/api/registry/score/[...pkg].get' + +export function usePackageScore(name: MaybeRefOrGetter) { + return useLazyFetch(() => `/api/registry/score/${toValue(name)}`) +} diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 108bb5fc8..6106fefbd 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -1101,6 +1101,9 @@ defineOgImageComponent('Package', { + + + { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName } = parsePackageParams(pkgParamSegments) + + try { + const { packageName } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + }) + + const response = await fetch(`${NPMS_API}/${encodeURIComponent(packageName)}`) + + if (!response.ok) { + throw createError({ statusCode: response.status, message: 'Failed to fetch npms score' }) + } + + const data = await response.json() + return data.score as NpmsScore + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to fetch package score from npms.io', + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `npms-score:${pkg}` + }, + }, +) From 037ffc102f0b45a4335b277f877a2284684bd299 Mon Sep 17 00:00:00 2001 From: "abderrahmen.mhemed" Date: Wed, 4 Feb 2026 10:00:09 +0100 Subject: [PATCH 2/6] feat: hue shift % based color change for scores --- app/components/Package/ScoreGauges.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/Package/ScoreGauges.vue b/app/components/Package/ScoreGauges.vue index 613cd38ba..11ce4de94 100644 --- a/app/components/Package/ScoreGauges.vue +++ b/app/components/Package/ScoreGauges.vue @@ -27,9 +27,9 @@ const scoreMetrics = computed(() => { }) function getScoreColor(percentage: number): string { - if (percentage < 40) return 'oklch(0.55 0.12 25)' - if (percentage < 70) return 'oklch(0.6 0.1 85)' - return 'oklch(0.55 0.1 145)' + // Interpolate hue from red (25) to green (145) based on percentage + const hue = 25 + (percentage / 100) * (145 - 25) + return `oklch(0.55 0.12 ${hue})` } From e9dad28d324923471b204f1a82b56844563b4f50 Mon Sep 17 00:00:00 2001 From: MhemedAbderrahmen Date: Wed, 4 Feb 2026 10:05:19 +0100 Subject: [PATCH 3/6] fix: file naming --- app/components/Package/{ScoreGauges.vue => ScoreBars.vue} | 1 - app/pages/package/[...package].vue | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename app/components/Package/{ScoreGauges.vue => ScoreBars.vue} (96%) diff --git a/app/components/Package/ScoreGauges.vue b/app/components/Package/ScoreBars.vue similarity index 96% rename from app/components/Package/ScoreGauges.vue rename to app/components/Package/ScoreBars.vue index 11ce4de94..a016dbd8a 100644 --- a/app/components/Package/ScoreGauges.vue +++ b/app/components/Package/ScoreBars.vue @@ -27,7 +27,6 @@ const scoreMetrics = computed(() => { }) function getScoreColor(percentage: number): string { - // Interpolate hue from red (25) to green (145) based on percentage const hue = 25 + (percentage / 100) * (145 - 25) return `oklch(0.55 0.12 ${hue})` } diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 6106fefbd..bd2439536 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -1102,7 +1102,7 @@ defineOgImageComponent('Package', { - + Date: Wed, 4 Feb 2026 10:33:44 +0100 Subject: [PATCH 4/6] feat: i18n strings and a11y test coverage --- test/nuxt/a11y.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 44af085b0..bf86d23d3 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -104,6 +104,7 @@ import { PackageManagerSelect, PackageMetricsBadges, PackagePlaygrounds, + PackageScoreBars, PackageReplacement, PackageSkeleton, PackageSkillsCard, @@ -1011,6 +1012,16 @@ describe('component accessibility audits', () => { }) }) + describe('PackageScoreBars', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(PackageScoreBars, { + props: { packageName: 'vue' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('PackageAccessControls', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(PackageAccessControls, { From 9dad20009fd278b361155ff496ab128f3e70a190 Mon Sep 17 00:00:00 2001 From: MhemedAbderrahmen Date: Wed, 4 Feb 2026 10:59:09 +0100 Subject: [PATCH 5/6] test: mock usePackageScore in a11y tests --- test/nuxt/a11y.spec.ts | 46 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index bf86d23d3..12a9ef9b4 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -4,6 +4,7 @@ import type { VueWrapper } from '@vue/test-utils' import 'axe-core' import type { AxeResults, RunOptions } from 'axe-core' import { afterEach, describe, expect, it } from 'vitest' +import { ref } from 'vue' // axe-core is a UMD module that exposes itself as window.axe in the browser declare const axe: { @@ -1013,9 +1014,52 @@ describe('component accessibility audits', () => { }) describe('PackageScoreBars', () => { - it('should have no accessibility violations', async () => { + it('should have no accessibility violations with score data', async () => { + const component = await mountSuspended(PackageScoreBars, { + props: { packageName: 'vue' }, + global: { + mocks: { + usePackageScore: () => ({ + data: ref({ + final: 0.85, + detail: { quality: 0.82, popularity: 0.91, maintenance: 0.78 }, + }), + status: ref('success'), + }), + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations in loading state', async () => { + const component = await mountSuspended(PackageScoreBars, { + props: { packageName: 'vue' }, + global: { + mocks: { + usePackageScore: () => ({ + data: ref(null), + status: ref('pending'), + }), + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when unavailable', async () => { const component = await mountSuspended(PackageScoreBars, { props: { packageName: 'vue' }, + global: { + mocks: { + usePackageScore: () => ({ + data: ref(null), + status: ref('error'), + }), + }, + }, }) const results = await runAxe(component) expect(results.violations).toEqual([]) From 725531ff75f8bf37093c3c2806d8850c2e48d933 Mon Sep 17 00:00:00 2001 From: MhemedAbderrahmen Date: Wed, 4 Feb 2026 13:42:20 +0100 Subject: [PATCH 6/6] refactor: npms api constant --- server/api/registry/badge/[type]/[...pkg].get.ts | 3 +-- server/api/registry/score/[...pkg].get.ts | 4 +--- shared/utils/constants.ts | 1 + 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index db6785e92..77830ed46 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -1,7 +1,7 @@ import * as v from 'valibot' import { createError, getRouterParam, getQuery, setHeader } from 'h3' import { PackageRouteParamsSchema } from '#shared/schemas/package' -import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' +import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED, NPMS_API } from '#shared/utils/constants' import { fetchNpmPackage } from '#server/utils/npm' import { assertValidPackageName } from '#shared/utils/npm' import { handleApiError } from '#server/utils/error-handler' @@ -9,7 +9,6 @@ import { handleApiError } from '#server/utils/error-handler' const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point' const OSV_QUERY_API = 'https://api.osv.dev/v1/query' const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size' -const NPMS_API = 'https://api.npms.io/v2/package' const QUERY_SCHEMA = v.object({ color: v.optional(v.string()), diff --git a/server/api/registry/score/[...pkg].get.ts b/server/api/registry/score/[...pkg].get.ts index badf0cd22..faa6bc4b6 100644 --- a/server/api/registry/score/[...pkg].get.ts +++ b/server/api/registry/score/[...pkg].get.ts @@ -1,8 +1,6 @@ import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' -import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' - -const NPMS_API = 'https://api.npms.io/v2/package' +import { CACHE_MAX_AGE_ONE_HOUR, NPMS_API } from '#shared/utils/constants' export interface NpmsScore { final: number diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 1229a2d50..84bb10b15 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -12,6 +12,7 @@ export const NPMX_SITE = 'https://npmx.dev' export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/' export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' export const NPM_REGISTRY = 'https://registry.npmjs.org' +export const NPMS_API = 'https://api.npms.io/v2/package' export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' export const ERROR_PACKAGE_REQUIREMENTS_FAILED =