diff --git a/app/components/Package/ScoreBars.vue b/app/components/Package/ScoreBars.vue new file mode 100644 index 000000000..a016dbd8a --- /dev/null +++ b/app/components/Package/ScoreBars.vue @@ -0,0 +1,65 @@ + + + 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 cf9f21d0d..e0ad5db50 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -1188,6 +1188,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}` + }, + }, +) 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 = diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index bbdf014d6..9a4c9fef5 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -123,6 +123,7 @@ import { PackageManagerSelect, PackageMetricsBadges, PackagePlaygrounds, + PackageScoreBars, PackageReplacement, PackageSkeleton, PackageSkillsCard, @@ -1079,6 +1080,59 @@ describe('component accessibility audits', () => { }) }) + describe('PackageScoreBars', () => { + 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([]) + }) + }) + describe('PackageAccessControls', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(PackageAccessControls, {