[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: add custom npmx.dev badge (#262)

authored by

Gugustinette and committed by
GitHub
2cef7ff3 ad8e2120

+138
+19
docs/content/2.guide/1.features.md
··· 89 89 | :icon{name="i-simple-icons-jsfiddle"} [JSFiddle](https://jsfiddle.net) | Online editor for web snippets | 90 90 | :icon{name="i-simple-icons-replit"} [Replit](https://replit.com) | Collaborative browser-based IDE | 91 91 | :icon{name="i-simple-icons-gitpod"} [Gitpod](https://gitpod.io) | Cloud development environments | 92 + 93 + ### Custom badges 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/YOUR_PACKAGE)` 96 + 97 + Do not forget to replace `YOUR_PACKAGE` with the actual package name. 98 + 99 + Here are some examples: 100 + 101 + ``` 102 + # Default 103 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt)](https://npmx.dev/nuxt) 104 + 105 + # Organization packages 106 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/@nuxt/kit)](https://npmx.dev/@nuxt/kit) 107 + 108 + # Version-specific badges 109 + [![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt/v/3.12.0)](https://npmx.dev/nuxt/v/3.12.0) 110 + ```
+74
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 { fetchNpmPackage } 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 + throw createError({ statusCode: 400, message: 'Package name is required.' }) 18 + } 19 + 20 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 21 + 22 + try { 23 + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { 24 + packageName: rawPackageName, 25 + version: rawVersion, 26 + }) 27 + 28 + assertValidPackageName(packageName) 29 + 30 + const label = `./ ${packageName}` 31 + 32 + const packument = await fetchNpmPackage(packageName) 33 + const value = requestedVersion ?? packument['dist-tags']?.latest ?? 'unknown' 34 + 35 + const leftWidth = measureTextWidth(label) 36 + const rightWidth = measureTextWidth(value) 37 + const totalWidth = leftWidth + rightWidth 38 + const height = 20 39 + 40 + const svg = ` 41 + <svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${label}: ${value}"> 42 + <clipPath id="r"> 43 + <rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/> 44 + </clipPath> 45 + <g clip-path="url(#r)"> 46 + <rect width="${leftWidth}" height="${height}" fill="#0a0a0a"/> 47 + <rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="#ffffff"/> 48 + </g> 49 + <g text-anchor="middle" font-family="'Geist', system-ui, -apple-system, sans-serif" font-size="11"> 50 + <text x="${leftWidth / 2}" y="14" fill="#ffffff">${label}</text> 51 + <text x="${leftWidth + rightWidth / 2}" y="14" fill="#000000">${value}</text> 52 + </g> 53 + </svg> 54 + `.trim() 55 + 56 + setHeader(event, 'Content-Type', 'image/svg+xml') 57 + 58 + return svg 59 + } catch (error: unknown) { 60 + handleApiError(error, { 61 + statusCode: 502, 62 + message: 'Failed to generate npm badge.', 63 + }) 64 + } 65 + }, 66 + { 67 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 68 + swr: true, 69 + getKey: event => { 70 + const pkg = getRouterParam(event, 'pkg') ?? '' 71 + return `badge:version:${pkg}` 72 + }, 73 + }, 74 + )
+45
tests/badge.spec.ts
··· 1 + import { expect, test } from '@nuxt/test-utils/playwright' 2 + 3 + function toLocalUrl(baseURL: string | undefined, path: string): string { 4 + if (!baseURL) return path 5 + return baseURL.endsWith('/') ? `${baseURL}${path.slice(1)}` : `${baseURL}${path}` 6 + } 7 + 8 + async function fetchBadge(page: { request: { get: (url: string) => Promise<any> } }, url: string) { 9 + const response = await page.request.get(url) 10 + const body = await response.text() 11 + return { response, body } 12 + } 13 + 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) 18 + 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') 23 + }) 24 + 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) 28 + 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') 33 + }) 34 + 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) 38 + 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') 44 + }) 45 + })