[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: more badges (#576)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Felix Schneider
Daniel Roe
and committed by
GitHub
e4f9d619 17c4a750

+512 -107
+80 -10
docs/content/2.guide/1.features.md
··· 92 92 93 93 ### Custom badges 94 94 95 - You can add custom npmx badges to your markdown files using the following syntax: `[![Open on npmx.dev](https://npmx.dev/api/registry/badge/YOUR_PACKAGE)](https://npmx.dev/package/YOUR_PACKAGE)` 95 + You can add custom npmx badges to your markdown files using the following syntax: 96 + `[![Open on npmx.dev](https://npmx.dev/api/registry/badge/TYPE/YOUR_PACKAGE)](https://npmx.dev/package/YOUR_PACKAGE)` 97 + 98 + > [!IMPORTANT] 99 + > Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKAGE` with the actual package name (e.g., `vue`, `lodash`, or `@nuxt/kit`). 100 + 101 + #### Available Badge Types 102 + 103 + - **version**: Shows the latest or specific version of the package. ![](https://img.shields.io/badge/%233b82f6-3b82f6) 104 + - **license**: Displays the package license (e.g., MIT, Apache-2.0). ![](https://img.shields.io/badge/%2322c55e-22c55e) 105 + - **size**: Shows the install size (via Bundlephobia) or unpacked size. ![](https://img.shields.io/badge/%23a855f7-a855f7) 106 + - **downloads**: Displays monthly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) 107 + - **downloads-day**: Displays daily download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) 108 + - **downloads-week**: Displays weekly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) 109 + - **downloads-month**: Alias for monthly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) 110 + - **downloads-year**: Displays yearly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) 111 + - **vulnerabilities**: Shows the number of vulnerabilities found via OSV. ![](https://img.shields.io/badge/%2322c55e-22c55e) / ![](https://img.shields.io/badge/%23ef4444-ef4444) 112 + - **dependencies**: Lists the total count of package dependencies. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) 113 + - **created**: Displays the date the package was first published. ![](https://img.shields.io/badge/%2364748b-64748b) 114 + - **updated**: Displays the date of the most recent modification. ![](https://img.shields.io/badge/%2364748b-64748b) 115 + - **engines**: Shows the supported Node.js version range. ![](https://img.shields.io/badge/%23eab308-eab308) 116 + - **types**: Indicates if TypeScript types are included. ![](https://img.shields.io/badge/%233b82f6-3b82f6) / ![](https://img.shields.io/badge/%2364748b-64748b) 117 + - **maintainers**: Displays the total count of package maintainers. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) 118 + - **deprecated**: Shows if the package is active or deprecated. ![](https://img.shields.io/badge/%2322c55e-22c55e) / ![](https://img.shields.io/badge/%23ef4444-ef4444) 119 + - **quality**: NPMS.io quality score based on linting and tests. ![](https://img.shields.io/badge/%23a855f7-a855f7) 120 + - **popularity**: NPMS.io popularity score based on downloads and stars. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) 121 + - **maintenance**: NPMS.io maintenance score based on activity. ![](https://img.shields.io/badge/%23eab308-eab308) 122 + - **score**: The overall NPMS.io combined score. ![](https://img.shields.io/badge/%233b82f6-3b82f6) 123 + - **name**: Simple badge displaying the package name. ![](https://img.shields.io/badge/%2364748b-64748b) 96 124 97 - Do not forget to replace `YOUR_PACKAGE` with the actual package name. 125 + #### Examples 98 126 99 - Here are some examples: 127 + ```markdown 128 + # Version Badge 100 129 101 - ``` 102 - # Default 103 - [![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt)](https://npmx.dev/package/nuxt) 130 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/nuxt)](https://npmx.dev/package/nuxt) 104 131 105 - # Organization packages 106 - [![Open on npmx.dev](https://npmx.dev/api/registry/badge/@nuxt/kit)](https://npmx.dev/package/@nuxt/kit) 132 + # License Badge 107 133 108 - # Version-specific badges 109 - [![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt/v/3.12.0)](https://npmx.dev/package/nuxt/v/3.12.0) 134 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/license/vue)](https://npmx.dev/package/vue) 135 + 136 + # Monthly Downloads 137 + 138 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/downloads/lodash)](https://npmx.dev/package/lodash) 139 + 140 + # Scoped Package (Install Size) 141 + 142 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/size/@nuxt/kit)](https://npmx.dev/package/@nuxt/kit) 143 + 144 + # Specific Version 145 + 146 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/react/v/18.0.0)](https://npmx.dev/package/react) 147 + 148 + # Quality Score 149 + 150 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/quality/pinia)](https://npmx.dev/package/pinia) 110 151 ``` 152 + 153 + #### Customization Parameters 154 + 155 + You can further customize your badges by appending query parameters to the badge URL. 156 + 157 + ##### `color` 158 + 159 + Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). 160 + 161 + - **Default**: Depends on the badge type (e.g., version is blue, downloads are orange). 162 + - **Usage**: `?color=HEX_CODE` 163 + 164 + | Example | URL | 165 + | :------------- | :------------------------------------ | 166 + | **Hot Pink** | `.../badge/version/nuxt?color=ff69b4` | 167 + | **Pure Black** | `.../badge/version/nuxt?color=000000` | 168 + | **Brand Blue** | `.../badge/version/nuxt?color=3b82f6` | 169 + 170 + ##### `name` 171 + 172 + When set to `true`, this parameter replaces the static category label (like "version" or "downloads/mo") with the actual name of the package. This is useful for brand-focused READMEs. 173 + 174 + - **Default**: `false` 175 + - **Usage**: `?name=true` 176 + 177 + | Type | Default Label | With `name=true` | 178 + | :------------ | :------------ | :--------------- | ------- | ------- | 179 + | **Version** | `version | 3.12.0` | `nuxt | 3.12.0` | 180 + | **Downloads** | `downloads/mo | 2M` | `lodash | 2M` |
-75
server/api/registry/badge/[...pkg].get.ts
··· 1 - import * as v from 'valibot' 2 - import { createError, getRouterParam, setHeader } from 'h3' 3 - import { PackageRouteParamsSchema } from '#shared/schemas/package' 4 - import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 5 - import { fetchLatestVersionWithFallback } from '#server/utils/npm' 6 - import { assertValidPackageName } from '#shared/utils/npm' 7 - import { handleApiError } from '#server/utils/error-handler' 8 - 9 - function measureTextWidth(text: string, charWidth = 6.2, paddingX = 6): number { 10 - return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2) 11 - } 12 - 13 - export default defineCachedEventHandler( 14 - async event => { 15 - const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 16 - if (pkgParamSegments.length === 0) { 17 - // TODO: throwing 404 rather than 400 as it's cacheable 18 - throw createError({ statusCode: 404, message: 'Package name is required.' }) 19 - } 20 - 21 - const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 22 - 23 - try { 24 - const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { 25 - packageName: rawPackageName, 26 - version: rawVersion, 27 - }) 28 - 29 - assertValidPackageName(packageName) 30 - 31 - const label = `./ ${packageName}` 32 - 33 - const value = 34 - requestedVersion ?? (await fetchLatestVersionWithFallback(packageName)) ?? 'unknown' 35 - 36 - const leftWidth = measureTextWidth(label) 37 - const rightWidth = measureTextWidth(value) 38 - const totalWidth = leftWidth + rightWidth 39 - const height = 20 40 - 41 - const svg = ` 42 - <svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${label}: ${value}"> 43 - <clipPath id="r"> 44 - <rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/> 45 - </clipPath> 46 - <g clip-path="url(#r)"> 47 - <rect width="${leftWidth}" height="${height}" fill="#0a0a0a"/> 48 - <rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="#ffffff"/> 49 - </g> 50 - <g text-anchor="middle" font-family="'Geist', system-ui, -apple-system, sans-serif" font-size="11"> 51 - <text x="${leftWidth / 2}" y="14" fill="#ffffff">${label}</text> 52 - <text x="${leftWidth + rightWidth / 2}" y="14" fill="#000000">${value}</text> 53 - </g> 54 - </svg> 55 - `.trim() 56 - 57 - setHeader(event, 'Content-Type', 'image/svg+xml') 58 - 59 - return svg 60 - } catch (error: unknown) { 61 - handleApiError(error, { 62 - statusCode: 502, 63 - message: 'Failed to generate npm badge.', 64 - }) 65 - } 66 - }, 67 - { 68 - maxAge: CACHE_MAX_AGE_ONE_HOUR, 69 - swr: true, 70 - getKey: event => { 71 - const pkg = getRouterParam(event, 'pkg') ?? '' 72 - return `badge:version:${pkg}` 73 - }, 74 - }, 75 - )
+329
server/api/registry/badge/[type]/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { createError, getRouterParam, getQuery, setHeader } from 'h3' 3 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 4 + import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' 5 + import { fetchNpmPackage } from '#server/utils/npm' 6 + import { assertValidPackageName } from '#shared/utils/npm' 7 + import { handleApiError } from '#server/utils/error-handler' 8 + 9 + const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point' 10 + const OSV_QUERY_API = 'https://api.osv.dev/v1/query' 11 + const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size' 12 + const NPMS_API = 'https://api.npms.io/v2/package' 13 + 14 + const QUERY_SCHEMA = v.object({ 15 + color: v.optional(v.string()), 16 + name: v.optional(v.string()), 17 + }) 18 + 19 + const COLORS = { 20 + blue: '#3b82f6', 21 + green: '#22c55e', 22 + purple: '#a855f7', 23 + orange: '#f97316', 24 + red: '#ef4444', 25 + cyan: '#06b6d4', 26 + slate: '#64748b', 27 + yellow: '#eab308', 28 + black: '#0a0a0a', 29 + white: '#ffffff', 30 + } 31 + 32 + function measureTextWidth(text: string): number { 33 + const charWidth = 7 34 + const paddingX = 8 35 + return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2) 36 + } 37 + 38 + function formatBytes(bytes: number): string { 39 + if (!+bytes) return '0 B' 40 + const k = 1024 41 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 42 + const i = Math.floor(Math.log(bytes) / Math.log(k)) 43 + const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2)) 44 + return `${value} ${sizes[i]}` 45 + } 46 + 47 + function formatNumber(num: number): string { 48 + return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format( 49 + num, 50 + ) 51 + } 52 + 53 + function formatDate(dateString: string): string { 54 + return new Date(dateString).toLocaleDateString('en-US', { 55 + month: 'short', 56 + day: 'numeric', 57 + year: 'numeric', 58 + }) 59 + } 60 + 61 + function getLatestVersion(pkgData: globalThis.Packument): string | undefined { 62 + return pkgData['dist-tags']?.latest 63 + } 64 + 65 + async function fetchDownloads( 66 + packageName: string, 67 + period: 'last-day' | 'last-week' | 'last-month' | 'last-year', 68 + ): Promise<number> { 69 + try { 70 + const response = await fetch(`${NPM_DOWNLOADS_API}/${period}/${packageName}`) 71 + const data = await response.json() 72 + return data.downloads ?? 0 73 + } catch { 74 + return 0 75 + } 76 + } 77 + 78 + async function fetchNpmsScore(packageName: string) { 79 + try { 80 + const response = await fetch(`${NPMS_API}/${encodeURIComponent(packageName)}`) 81 + const data = await response.json() 82 + return data.score 83 + } catch { 84 + return null 85 + } 86 + } 87 + 88 + async function fetchVulnerabilities(packageName: string, version: string): Promise<number> { 89 + try { 90 + const response = await fetch(OSV_QUERY_API, { 91 + method: 'POST', 92 + body: JSON.stringify({ 93 + version, 94 + package: { name: packageName, ecosystem: 'npm' }, 95 + }), 96 + }) 97 + const data = await response.json() 98 + return data.vulns?.length ?? 0 99 + } catch { 100 + return 0 101 + } 102 + } 103 + 104 + async function fetchInstallSize(packageName: string, version: string): Promise<number | null> { 105 + try { 106 + const response = await fetch(`${BUNDLEPHOBIA_API}?package=${packageName}@${version}`) 107 + const data = await response.json() 108 + return data.size ?? null 109 + } catch { 110 + return null 111 + } 112 + } 113 + 114 + const badgeStrategies = { 115 + 'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => { 116 + const value = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown' 117 + return { label: 'version', value, color: COLORS.blue } 118 + }, 119 + 120 + 'license': async (pkgData: globalThis.Packument) => { 121 + const latest = getLatestVersion(pkgData) 122 + const versionData = latest ? pkgData.versions?.[latest] : undefined 123 + const value = versionData?.license ?? 'unknown' 124 + return { label: 'license', value, color: COLORS.green } 125 + }, 126 + 127 + 'size': async (pkgData: globalThis.Packument) => { 128 + const latest = getLatestVersion(pkgData) 129 + const versionData = latest ? pkgData.versions?.[latest] : undefined 130 + let bytes = versionData?.dist?.unpackedSize ?? 0 131 + if (latest) { 132 + const installSize = await fetchInstallSize(pkgData.name, latest) 133 + if (installSize !== null) bytes = installSize 134 + } 135 + return { label: 'install size', value: formatBytes(bytes), color: COLORS.purple } 136 + }, 137 + 138 + 'downloads': async (pkgData: globalThis.Packument) => { 139 + const count = await fetchDownloads(pkgData.name, 'last-month') 140 + return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange } 141 + }, 142 + 143 + 'downloads-day': async (pkgData: globalThis.Packument) => { 144 + const count = await fetchDownloads(pkgData.name, 'last-day') 145 + return { label: 'downloads/day', value: formatNumber(count), color: COLORS.orange } 146 + }, 147 + 148 + 'downloads-week': async (pkgData: globalThis.Packument) => { 149 + const count = await fetchDownloads(pkgData.name, 'last-week') 150 + return { label: 'downloads/wk', value: formatNumber(count), color: COLORS.orange } 151 + }, 152 + 153 + 'downloads-month': async (pkgData: globalThis.Packument) => { 154 + const count = await fetchDownloads(pkgData.name, 'last-month') 155 + return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange } 156 + }, 157 + 158 + 'downloads-year': async (pkgData: globalThis.Packument) => { 159 + const count = await fetchDownloads(pkgData.name, 'last-year') 160 + return { label: 'downloads/yr', value: formatNumber(count), color: COLORS.orange } 161 + }, 162 + 163 + 'vulnerabilities': async (pkgData: globalThis.Packument) => { 164 + const latest = getLatestVersion(pkgData) 165 + const count = latest ? await fetchVulnerabilities(pkgData.name, latest) : 0 166 + const isSafe = count === 0 167 + const color = isSafe ? COLORS.green : COLORS.red 168 + return { label: 'vulns', value: String(count), color } 169 + }, 170 + 171 + 'dependencies': async (pkgData: globalThis.Packument) => { 172 + const latest = getLatestVersion(pkgData) 173 + const versionData = latest ? pkgData.versions?.[latest] : undefined 174 + const count = Object.keys(versionData?.dependencies ?? {}).length 175 + return { label: 'dependencies', value: String(count), color: COLORS.cyan } 176 + }, 177 + 178 + 'created': async (pkgData: globalThis.Packument) => { 179 + const dateStr = pkgData.time?.created ?? pkgData.time?.modified 180 + return { label: 'created', value: formatDate(dateStr), color: COLORS.slate } 181 + }, 182 + 183 + 'updated': async (pkgData: globalThis.Packument) => { 184 + const dateStr = pkgData.time?.modified ?? pkgData.time?.created ?? new Date().toISOString() 185 + return { label: 'updated', value: formatDate(dateStr), color: COLORS.slate } 186 + }, 187 + 188 + 'engines': async (pkgData: globalThis.Packument) => { 189 + const latest = getLatestVersion(pkgData) 190 + const nodeVersion = (latest && pkgData.versions?.[latest]?.engines?.node) ?? '*' 191 + return { label: 'node', value: nodeVersion, color: COLORS.yellow } 192 + }, 193 + 194 + 'types': async (pkgData: globalThis.Packument) => { 195 + const latest = getLatestVersion(pkgData) 196 + const versionData = latest ? pkgData.versions?.[latest] : undefined 197 + const hasTypes = !!(versionData?.types || versionData?.typings) 198 + const value = hasTypes ? 'included' : 'missing' 199 + const color = hasTypes ? COLORS.blue : COLORS.slate 200 + return { label: 'types', value, color } 201 + }, 202 + 203 + 'maintainers': async (pkgData: globalThis.Packument) => { 204 + const count = pkgData.maintainers?.length ?? 0 205 + return { label: 'maintainers', value: String(count), color: COLORS.cyan } 206 + }, 207 + 208 + 'deprecated': async (pkgData: globalThis.Packument) => { 209 + const latest = getLatestVersion(pkgData) 210 + const isDeprecated = !!(latest && pkgData.versions?.[latest]?.deprecated) 211 + return { 212 + label: 'status', 213 + value: isDeprecated ? 'deprecated' : 'active', 214 + color: isDeprecated ? COLORS.red : COLORS.green, 215 + } 216 + }, 217 + 218 + 'quality': async (pkgData: globalThis.Packument) => { 219 + const score = await fetchNpmsScore(pkgData.name) 220 + const value = score ? `${Math.round(score.detail.quality * 100)}%` : 'unknown' 221 + return { label: 'quality', value, color: COLORS.purple } 222 + }, 223 + 224 + 'popularity': async (pkgData: globalThis.Packument) => { 225 + const score = await fetchNpmsScore(pkgData.name) 226 + const value = score ? `${Math.round(score.detail.popularity * 100)}%` : 'unknown' 227 + return { label: 'popularity', value, color: COLORS.cyan } 228 + }, 229 + 230 + 'maintenance': async (pkgData: globalThis.Packument) => { 231 + const score = await fetchNpmsScore(pkgData.name) 232 + const value = score ? `${Math.round(score.detail.maintenance * 100)}%` : 'unknown' 233 + return { label: 'maintenance', value, color: COLORS.yellow } 234 + }, 235 + 236 + 'score': async (pkgData: globalThis.Packument) => { 237 + const score = await fetchNpmsScore(pkgData.name) 238 + const value = score ? `${Math.round(score.final * 100)}%` : 'unknown' 239 + return { label: 'score', value, color: COLORS.blue } 240 + }, 241 + } 242 + 243 + const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]]) 244 + 245 + export default defineCachedEventHandler( 246 + async event => { 247 + const query = getQuery(event) 248 + const typeParam = getRouterParam(event, 'type') 249 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 250 + 251 + if (pkgParamSegments.length === 0) { 252 + // TODO: throwing 404 rather than 400 as it's cacheable 253 + throw createError({ statusCode: 404, message: 'Package name is required.' }) 254 + } 255 + 256 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 257 + 258 + try { 259 + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { 260 + packageName: rawPackageName, 261 + version: rawVersion, 262 + }) 263 + 264 + const queryParams = v.safeParse(QUERY_SCHEMA, query) 265 + const userColor = queryParams.success ? queryParams.output.color : undefined 266 + const showName = queryParams.success && queryParams.output.name === 'true' 267 + 268 + const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam) 269 + const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version' 270 + const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies] 271 + 272 + assertValidPackageName(packageName) 273 + 274 + const pkgData = await fetchNpmPackage(packageName) 275 + const strategyResult = await strategy(pkgData, requestedVersion) 276 + 277 + const finalLabel = showName ? packageName : strategyResult.label 278 + const finalValue = strategyResult.value 279 + 280 + const rawColor = userColor ?? strategyResult.color 281 + const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}` 282 + 283 + const leftWidth = measureTextWidth(finalLabel) 284 + const rightWidth = measureTextWidth(finalValue) 285 + const totalWidth = leftWidth + rightWidth 286 + const height = 20 287 + 288 + const svg = ` 289 + <svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${finalLabel}: ${finalValue}"> 290 + <clipPath id="r"> 291 + <rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/> 292 + </clipPath> 293 + <g clip-path="url(#r)"> 294 + <rect width="${leftWidth}" height="${height}" fill="#0a0a0a"/> 295 + <rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/> 296 + </g> 297 + <g text-anchor="middle" font-family="'Geist', system-ui, -apple-system, sans-serif" font-size="11"> 298 + <text x="${leftWidth / 2}" y="14" fill="#ffffff">${finalLabel}</text> 299 + <text x="${leftWidth + rightWidth / 2}" y="14" fill="#ffffff">${finalValue}</text> 300 + </g> 301 + </svg> 302 + `.trim() 303 + 304 + setHeader(event, 'Content-Type', 'image/svg+xml') 305 + setHeader( 306 + event, 307 + 'Cache-Control', 308 + `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`, 309 + ) 310 + 311 + return svg 312 + } catch (error: unknown) { 313 + handleApiError(error, { 314 + statusCode: 502, 315 + message: ERROR_NPM_FETCH_FAILED, 316 + }) 317 + } 318 + }, 319 + { 320 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 321 + swr: true, 322 + getKey: event => { 323 + const type = getRouterParam(event, 'type') ?? 'version' 324 + const pkg = getRouterParam(event, 'pkg') ?? '' 325 + const query = getQuery(event) 326 + return `badge:${type}:${pkg}:${JSON.stringify(query)}` 327 + }, 328 + }, 329 + )
+103 -22
test/e2e/badge.spec.ts
··· 12 12 } 13 13 14 14 test.describe('badge API', () => { 15 - test('unscoped package badge renders SVG', async ({ page, baseURL }) => { 16 - const url = toLocalUrl(baseURL, '/api/registry/badge/nuxt') 17 - const { response, body } = await fetchBadge(page, url) 15 + const badgeMap: Record<string, string> = { 16 + 'version': 'version', 17 + 'license': 'license', 18 + 'size': 'install size', 19 + 'downloads': 'downloads/mo', 20 + 'downloads-day': 'downloads/day', 21 + 'downloads-week': 'downloads/wk', 22 + 'downloads-month': 'downloads/mo', 23 + 'downloads-year': 'downloads/yr', 24 + 'vulnerabilities': 'vulns', 25 + 'dependencies': 'dependencies', 26 + 'updated': 'updated', 27 + 'engines': 'node', 28 + 'types': 'types', 29 + 'created': 'created', 30 + 'maintainers': 'maintainers', 31 + 'deprecated': 'status', 32 + 'quality': 'quality', 33 + 'popularity': 'popularity', 34 + 'maintenance': 'maintenance', 35 + 'score': 'score', 36 + } 37 + 38 + const percentageTypes = new Set(['quality', 'popularity', 'maintenance', 'score']) 39 + 40 + for (const [type, expectedLabel] of Object.entries(badgeMap)) { 41 + test.describe(`${type} badge`, () => { 42 + test('renders correct label', async ({ page, baseURL }) => { 43 + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/nuxt`) 44 + const { response, body } = await fetchBadge(page, url) 45 + 46 + expect(response.status()).toBe(200) 47 + expect(response.headers()['content-type']).toContain('image/svg+xml') 48 + expect(body).toContain(expectedLabel) 49 + }) 50 + 51 + test('scoped package renders successfully', async ({ page, baseURL }) => { 52 + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/@nuxt/kit`) 53 + const { response } = await fetchBadge(page, url) 54 + 55 + expect(response.status()).toBe(200) 56 + }) 57 + 58 + test('explicit version badge renders successfully', async ({ page, baseURL }) => { 59 + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/nuxt/v/3.12.0`) 60 + const { response, body } = await fetchBadge(page, url) 61 + 62 + expect(response.status()).toBe(200) 63 + if (type === 'version') { 64 + expect(body).toContain('3.12.0') 65 + } 66 + }) 67 + 68 + test('respects name=true parameter', async ({ page, baseURL }) => { 69 + const packageName = 'nuxt' 70 + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/${packageName}?name=true`) 71 + const { body } = await fetchBadge(page, url) 72 + 73 + expect(body).toContain(packageName) 74 + expect(body).not.toContain(expectedLabel) 75 + }) 76 + 77 + if (percentageTypes.has(type)) { 78 + test('contains percentage value', async ({ page, baseURL }) => { 79 + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/vue`) 80 + const { body } = await fetchBadge(page, url) 81 + 82 + expect(body).toMatch(/\d+%|unknown/) 83 + }) 84 + } 85 + }) 86 + } 87 + 88 + test.describe('specific scenarios', () => { 89 + test('downloads-year handles large numbers', async ({ page, baseURL }) => { 90 + const url = toLocalUrl(baseURL, '/api/registry/badge/downloads-year/lodash') 91 + const { body } = await fetchBadge(page, url) 92 + 93 + expect(body).toContain('downloads/yr') 94 + expect(body).not.toContain('NaN') 95 + }) 96 + 97 + test('deprecated badge shows active for non-deprecated packages', async ({ page, baseURL }) => { 98 + const url = toLocalUrl(baseURL, '/api/registry/badge/deprecated/vue') 99 + const { body } = await fetchBadge(page, url) 18 100 19 - expect(response.status()).toBe(200) 20 - expect(response.headers()['content-type']).toContain('image/svg+xml') 21 - expect(body).toContain('<svg') 22 - expect(body).toContain('nuxt') 101 + expect(body).toContain('active') 102 + }) 23 103 }) 24 104 25 - test('scoped package badge renders SVG', async ({ page, baseURL }) => { 26 - const url = toLocalUrl(baseURL, '/api/registry/badge/@nuxt/kit') 27 - const { response, body } = await fetchBadge(page, url) 105 + test('custom color parameter is applied to SVG', async ({ page, baseURL }) => { 106 + const customColor = 'ff69b4' 107 + const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?color=${customColor}`) 108 + const { body } = await fetchBadge(page, url) 28 109 29 - expect(response.status()).toBe(200) 30 - expect(response.headers()['content-type']).toContain('image/svg+xml') 31 - expect(body).toContain('<svg') 32 - expect(body).toContain('@nuxt/kit') 110 + expect(body).toContain(`fill="#${customColor}"`) 111 + }) 112 + 113 + test('invalid badge type defaults to version strategy', async ({ page, baseURL }) => { 114 + const url = toLocalUrl(baseURL, '/api/registry/badge/invalid-type/nuxt') 115 + const { body } = await fetchBadge(page, url) 116 + 117 + expect(body).toContain('version') 33 118 }) 34 119 35 - test('explicit version badge includes requested version', async ({ page, baseURL }) => { 36 - const url = toLocalUrl(baseURL, '/api/registry/badge/nuxt/v/3.12.0') 37 - const { response, body } = await fetchBadge(page, url) 120 + test('missing package returns 404', async ({ page, baseURL }) => { 121 + const url = toLocalUrl(baseURL, '/api/registry/badge/version/') 122 + const { response } = await fetchBadge(page, url) 38 123 39 - expect(response.status()).toBe(200) 40 - expect(response.headers()['content-type']).toContain('image/svg+xml') 41 - expect(body).toContain('<svg') 42 - expect(body).toContain('nuxt') 43 - expect(body).toContain('3.12.0') 124 + expect(response.status()).toBe(404) 44 125 }) 45 126 })