From d105dcced833f6bf7ca960347c37444a72a22056 Mon Sep 17 00:00:00 2001
From: Felix Schneider <99918022+trueberryless@users.noreply.github.com>
Date: Sun, 1 Feb 2026 00:01:41 +0100
Subject: [PATCH 1/5] feat: more badges
---
server/api/registry/badge/[...pkg].get.ts | 75 -----
.../api/registry/badge/[type]/[...pkg].get.ts | 283 ++++++++++++++++++
2 files changed, 283 insertions(+), 75 deletions(-)
delete mode 100644 server/api/registry/badge/[...pkg].get.ts
create mode 100644 server/api/registry/badge/[type]/[...pkg].get.ts
diff --git a/server/api/registry/badge/[...pkg].get.ts b/server/api/registry/badge/[...pkg].get.ts
deleted file mode 100644
index 7e3ecde7d..000000000
--- a/server/api/registry/badge/[...pkg].get.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as v from 'valibot'
-import { createError, getRouterParam, setHeader } from 'h3'
-import { PackageRouteParamsSchema } from '#shared/schemas/package'
-import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
-import { fetchNpmPackage } from '#server/utils/npm'
-import { assertValidPackageName } from '#shared/utils/npm'
-import { handleApiError } from '#server/utils/error-handler'
-
-function measureTextWidth(text: string, charWidth = 6.2, paddingX = 6): number {
- return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
-}
-
-export default defineCachedEventHandler(
- async event => {
- const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
- if (pkgParamSegments.length === 0) {
- // TODO: throwing 404 rather than 400 as it's cacheable
- throw createError({ statusCode: 404, message: 'Package name is required.' })
- }
-
- const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
-
- try {
- const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
- packageName: rawPackageName,
- version: rawVersion,
- })
-
- assertValidPackageName(packageName)
-
- const label = `./ ${packageName}`
-
- const value =
- requestedVersion ?? (await fetchNpmPackage(packageName))['dist-tags']?.latest ?? 'unknown'
-
- const leftWidth = measureTextWidth(label)
- const rightWidth = measureTextWidth(value)
- const totalWidth = leftWidth + rightWidth
- const height = 20
-
- const svg = `
-
- `.trim()
-
- setHeader(event, 'Content-Type', 'image/svg+xml')
-
- return svg
- } catch (error: unknown) {
- handleApiError(error, {
- statusCode: 502,
- message: 'Failed to generate npm badge.',
- })
- }
- },
- {
- maxAge: CACHE_MAX_AGE_ONE_HOUR,
- swr: true,
- getKey: event => {
- const pkg = getRouterParam(event, 'pkg') ?? ''
- return `badge:version:${pkg}`
- },
- },
-)
diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts
new file mode 100644
index 000000000..4df8228a6
--- /dev/null
+++ b/server/api/registry/badge/[type]/[...pkg].get.ts
@@ -0,0 +1,283 @@
+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 { fetchNpmPackage } from '#server/utils/npm'
+import { assertValidPackageName } from '#shared/utils/npm'
+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 QUERY_SCHEMA = v.object({
+ color: v.optional(v.string()),
+ name: v.optional(v.string()),
+})
+
+const COLORS = {
+ blue: '#3b82f6',
+ green: '#22c55e',
+ purple: '#a855f7',
+ orange: '#f97316',
+ red: '#ef4444',
+ cyan: '#06b6d4',
+ slate: '#64748b',
+ yellow: '#eab308',
+ black: '#0a0a0a',
+ white: '#ffffff',
+}
+
+function measureTextWidth(text: string): number {
+ const charWidth = 7
+ const paddingX = 8
+ return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
+}
+
+function formatBytes(bytes: number): string {
+ if (!+bytes) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
+ return `${value} ${sizes[i]}`
+}
+
+function formatNumber(num: number): string {
+ return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(
+ num,
+ )
+}
+
+function formatDate(dateString: string): string {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
+
+function getLatestVersion(pkgData: globalThis.Packument): string | undefined {
+ return pkgData['dist-tags']?.latest
+}
+
+async function fetchDownloads(
+ packageName: string,
+ period: 'last-month' | 'last-week',
+): Promise {
+ try {
+ const response = await fetch(`${NPM_DOWNLOADS_API}/${period}/${packageName}`)
+ const data = await response.json()
+ return data.downloads ?? 0
+ } catch {
+ return 0
+ }
+}
+
+async function fetchVulnerabilities(packageName: string, version: string): Promise {
+ try {
+ const response = await fetch(OSV_QUERY_API, {
+ method: 'POST',
+ body: JSON.stringify({
+ version,
+ package: { name: packageName, ecosystem: 'npm' },
+ }),
+ })
+ const data = await response.json()
+ return data.vulns?.length ?? 0
+ } catch {
+ return 0
+ }
+}
+
+async function fetchInstallSize(packageName: string, version: string): Promise {
+ try {
+ const response = await fetch(`${BUNDLEPHOBIA_API}?package=${packageName}@${version}`)
+ const data = await response.json()
+ return data.size ?? null
+ } catch {
+ return null
+ }
+}
+
+const badgeStrategies = {
+ 'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
+ const value = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
+ return { label: 'version', value, color: COLORS.blue }
+ },
+
+ 'license': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const value = versionData?.license ?? 'unknown'
+ return { label: 'license', value, color: COLORS.green }
+ },
+
+ 'size': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+
+ // Fallback to unpacked size if bundlephobia fails or latest is missing
+ let bytes = versionData?.dist?.unpackedSize ?? 0
+
+ if (latest) {
+ const installSize = await fetchInstallSize(pkgData.name, latest)
+ if (installSize !== null) bytes = installSize
+ }
+
+ return { label: 'install size', value: formatBytes(bytes), color: COLORS.purple }
+ },
+
+ 'downloads': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-month')
+ return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'downloads-week': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-week')
+ return { label: 'downloads/wk', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'vulnerabilities': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const count = latest ? await fetchVulnerabilities(pkgData.name, latest) : 0
+ const isSafe = count === 0
+ const color = isSafe ? COLORS.green : COLORS.red
+ return { label: 'vulns', value: String(count), color }
+ },
+
+ 'dependencies': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const count = Object.keys(versionData?.dependencies ?? {}).length
+ return { label: 'dependencies', value: String(count), color: COLORS.cyan }
+ },
+
+ 'created': async (pkgData: globalThis.Packument) => {
+ const dateStr = pkgData.time?.created ?? pkgData.time?.modified
+ return { label: 'created', value: formatDate(dateStr), color: COLORS.slate }
+ },
+
+ 'updated': async (pkgData: globalThis.Packument) => {
+ const dateStr = pkgData.time?.modified ?? pkgData.time?.created ?? new Date().toISOString()
+ return { label: 'updated', value: formatDate(dateStr), color: COLORS.slate }
+ },
+
+ 'engines': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const nodeVersion = (latest && pkgData.versions?.[latest]?.engines?.node) ?? '*'
+ return { label: 'node', value: nodeVersion, color: COLORS.yellow }
+ },
+
+ 'types': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const hasTypes = !!(versionData?.types || versionData?.typings)
+ const value = hasTypes ? 'included' : 'missing'
+ const color = hasTypes ? COLORS.blue : COLORS.slate
+ return { label: 'types', value, color }
+ },
+
+ 'maintainers': async (pkgData: globalThis.Packument) => {
+ const count = pkgData.maintainers?.length ?? 0
+ return { label: 'maintainers', value: String(count), color: COLORS.cyan }
+ },
+
+ 'deprecated': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const isDeprecated = !!(latest && pkgData.versions?.[latest]?.deprecated)
+ return {
+ label: 'status',
+ value: isDeprecated ? 'deprecated' : 'active',
+ color: isDeprecated ? COLORS.red : COLORS.green,
+ }
+ },
+}
+
+const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies))
+
+export default defineCachedEventHandler(
+ async event => {
+ const query = getQuery(event)
+ const typeParam = getRouterParam(event, 'type')
+ const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
+
+ if (pkgParamSegments.length === 0) {
+ // TODO: throwing 404 rather than 400 as it's cacheable
+ throw createError({ statusCode: 404, message: 'Package name is required.' })
+ }
+
+ const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
+
+ try {
+ const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
+ packageName: rawPackageName,
+ version: rawVersion,
+ })
+
+ const queryParams = v.safeParse(QUERY_SCHEMA, query)
+ const userColor = queryParams.success ? queryParams.output.color : undefined
+ const showName = queryParams.success && queryParams.output.name === 'true'
+
+ const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
+ const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
+ const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]
+
+ assertValidPackageName(packageName)
+
+ const pkgData = await fetchNpmPackage(packageName)
+ const strategyResult = await strategy(pkgData, requestedVersion)
+
+ const finalLabel = showName ? packageName : strategyResult.label
+ const finalValue = strategyResult.value
+
+ const rawColor = userColor ?? strategyResult.color
+ const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
+
+ const leftWidth = measureTextWidth(finalLabel)
+ const rightWidth = measureTextWidth(finalValue)
+ const totalWidth = leftWidth + rightWidth
+ const height = 20
+
+ const svg = `
+
+ `.trim()
+
+ setHeader(event, 'Content-Type', 'image/svg+xml')
+ setHeader(
+ event,
+ 'Cache-Control',
+ `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
+ )
+
+ return svg
+ } catch (error: unknown) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_NPM_FETCH_FAILED,
+ })
+ }
+ },
+ {
+ maxAge: CACHE_MAX_AGE_ONE_HOUR,
+ swr: true,
+ getKey: event => {
+ const type = getRouterParam(event, 'type') ?? 'version'
+ const pkg = getRouterParam(event, 'pkg') ?? ''
+ const query = getQuery(event)
+ return `badge:${type}:${pkg}:${JSON.stringify(query)}`
+ },
+ },
+)
From c54459bdd0ee20ac7ce12038600013c0dd1f8e93 Mon Sep 17 00:00:00 2001
From: Felix Schneider <99918022+trueberryless@users.noreply.github.com>
Date: Sun, 1 Feb 2026 00:37:51 +0100
Subject: [PATCH 2/5] test: update test cases for badges
---
test/e2e/badge.spec.ts | 92 +++++++++++++++++++++++++++++++-----------
1 file changed, 69 insertions(+), 23 deletions(-)
diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts
index 65d354b1b..0f1833771 100644
--- a/test/e2e/badge.spec.ts
+++ b/test/e2e/badge.spec.ts
@@ -12,34 +12,80 @@ async function fetchBadge(page: { request: { get: (url: string) => Promise
}
test.describe('badge API', () => {
- test('unscoped package badge renders SVG', async ({ page, baseURL }) => {
- const url = toLocalUrl(baseURL, '/api/registry/badge/nuxt')
- const { response, body } = await fetchBadge(page, url)
-
- expect(response.status()).toBe(200)
- expect(response.headers()['content-type']).toContain('image/svg+xml')
- expect(body).toContain('