Skip to content

Commit 2cef7ff

Browse files
authored
feat: add custom npmx.dev badge (#262)
1 parent ad8e212 commit 2cef7ff

3 files changed

Lines changed: 138 additions & 0 deletions

File tree

docs/content/2.guide/1.features.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,22 @@ Quick access to online development environments detected from package READMEs:
8989
| :icon{name="i-simple-icons-jsfiddle"} [JSFiddle](https://jsfiddle.net) | Online editor for web snippets |
9090
| :icon{name="i-simple-icons-replit"} [Replit](https://replit.com) | Collaborative browser-based IDE |
9191
| :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+
```
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
)

tests/badge.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
})

0 commit comments

Comments
 (0)