diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index cef827452..81eee0564 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -92,19 +92,89 @@ Quick access to online development environments detected from package READMEs: ### Custom badges -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)` +You can add custom npmx badges to your markdown files using the following syntax: +`[![Open on npmx.dev](https://npmx.dev/api/registry/badge/TYPE/YOUR_PACKAGE)](https://npmx.dev/package/YOUR_PACKAGE)` -Do not forget to replace `YOUR_PACKAGE` with the actual package name. +> [!IMPORTANT] +> 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`). -Here are some examples: +#### Available Badge Types -``` -# Default -[![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt)](https://npmx.dev/package/nuxt) +- **version**: Shows the latest or specific version of the package. ![](https://img.shields.io/badge/%233b82f6-3b82f6) +- **license**: Displays the package license (e.g., MIT, Apache-2.0). ![](https://img.shields.io/badge/%2322c55e-22c55e) +- **size**: Shows the install size (via Bundlephobia) or unpacked size. ![](https://img.shields.io/badge/%23a855f7-a855f7) +- **downloads**: Displays monthly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) +- **downloads-day**: Displays daily download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) +- **downloads-week**: Displays weekly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) +- **downloads-month**: Alias for monthly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) +- **downloads-year**: Displays yearly download statistics. ![](https://img.shields.io/badge/%23f97316-f97316) +- **vulnerabilities**: Shows the number of vulnerabilities found via OSV. ![](https://img.shields.io/badge/%2322c55e-22c55e) / ![](https://img.shields.io/badge/%23ef4444-ef4444) +- **dependencies**: Lists the total count of package dependencies. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) +- **created**: Displays the date the package was first published. ![](https://img.shields.io/badge/%2364748b-64748b) +- **updated**: Displays the date of the most recent modification. ![](https://img.shields.io/badge/%2364748b-64748b) +- **engines**: Shows the supported Node.js version range. ![](https://img.shields.io/badge/%23eab308-eab308) +- **types**: Indicates if TypeScript types are included. ![](https://img.shields.io/badge/%233b82f6-3b82f6) / ![](https://img.shields.io/badge/%2364748b-64748b) +- **maintainers**: Displays the total count of package maintainers. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) +- **deprecated**: Shows if the package is active or deprecated. ![](https://img.shields.io/badge/%2322c55e-22c55e) / ![](https://img.shields.io/badge/%23ef4444-ef4444) +- **quality**: NPMS.io quality score based on linting and tests. ![](https://img.shields.io/badge/%23a855f7-a855f7) +- **popularity**: NPMS.io popularity score based on downloads and stars. ![](https://img.shields.io/badge/%2306b6d4-06b6d4) +- **maintenance**: NPMS.io maintenance score based on activity. ![](https://img.shields.io/badge/%23eab308-eab308) +- **score**: The overall NPMS.io combined score. ![](https://img.shields.io/badge/%233b82f6-3b82f6) +- **name**: Simple badge displaying the package name. ![](https://img.shields.io/badge/%2364748b-64748b) + +#### Examples + +```markdown +# Version Badge + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/nuxt)](https://npmx.dev/package/nuxt) + +# License Badge + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/license/vue)](https://npmx.dev/package/vue) + +# Monthly Downloads + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/downloads/lodash)](https://npmx.dev/package/lodash) + +# Scoped Package (Install Size) + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/size/@nuxt/kit)](https://npmx.dev/package/@nuxt/kit) + +# Specific Version -# Organization packages -[![Open on npmx.dev](https://npmx.dev/api/registry/badge/@nuxt/kit)](https://npmx.dev/package/@nuxt/kit) +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/react/v/18.0.0)](https://npmx.dev/package/react) -# Version-specific badges -[![Open on npmx.dev](https://npmx.dev/api/registry/badge/nuxt/v/3.12.0)](https://npmx.dev/package/nuxt/v/3.12.0) +# Quality Score + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/quality/pinia)](https://npmx.dev/package/pinia) ``` + +#### Customization Parameters + +You can further customize your badges by appending query parameters to the badge URL. + +##### `color` + +Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). + +- **Default**: Depends on the badge type (e.g., version is blue, downloads are orange). +- **Usage**: `?color=HEX_CODE` + +| Example | URL | +| :------------- | :------------------------------------ | +| **Hot Pink** | `.../badge/version/nuxt?color=ff69b4` | +| **Pure Black** | `.../badge/version/nuxt?color=000000` | +| **Brand Blue** | `.../badge/version/nuxt?color=3b82f6` | + +##### `name` + +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. + +- **Default**: `false` +- **Usage**: `?name=true` + +| Type | Default Label | With `name=true` | +| :------------ | :------------ | :--------------- | ------- | ------- | +| **Version** | `version | 3.12.0` | `nuxt | 3.12.0` | +| **Downloads** | `downloads/mo | 2M` | `lodash | 2M` | diff --git a/server/api/registry/badge/[...pkg].get.ts b/server/api/registry/badge/[...pkg].get.ts deleted file mode 100644 index 9bf5573b9..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 { fetchLatestVersionWithFallback } 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 fetchLatestVersionWithFallback(packageName)) ?? 'unknown' - - const leftWidth = measureTextWidth(label) - const rightWidth = measureTextWidth(value) - const totalWidth = leftWidth + rightWidth - const height = 20 - - const svg = ` - - - - - - - - - - ${label} - ${value} - - - `.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..8f6a56d45 --- /dev/null +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -0,0 +1,329 @@ +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 NPMS_API = 'https://api.npms.io/v2/package' + +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-day' | 'last-week' | 'last-month' | 'last-year', +): 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 fetchNpmsScore(packageName: string) { + try { + const response = await fetch(`${NPMS_API}/${encodeURIComponent(packageName)}`) + const data = await response.json() + return data.score + } catch { + return null + } +} + +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 + 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-day': async (pkgData: globalThis.Packument) => { + const count = await fetchDownloads(pkgData.name, 'last-day') + return { label: 'downloads/day', 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 } + }, + + 'downloads-month': async (pkgData: globalThis.Packument) => { + const count = await fetchDownloads(pkgData.name, 'last-month') + return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange } + }, + + 'downloads-year': async (pkgData: globalThis.Packument) => { + const count = await fetchDownloads(pkgData.name, 'last-year') + return { label: 'downloads/yr', 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, + } + }, + + 'quality': async (pkgData: globalThis.Packument) => { + const score = await fetchNpmsScore(pkgData.name) + const value = score ? `${Math.round(score.detail.quality * 100)}%` : 'unknown' + return { label: 'quality', value, color: COLORS.purple } + }, + + 'popularity': async (pkgData: globalThis.Packument) => { + const score = await fetchNpmsScore(pkgData.name) + const value = score ? `${Math.round(score.detail.popularity * 100)}%` : 'unknown' + return { label: 'popularity', value, color: COLORS.cyan } + }, + + 'maintenance': async (pkgData: globalThis.Packument) => { + const score = await fetchNpmsScore(pkgData.name) + const value = score ? `${Math.round(score.detail.maintenance * 100)}%` : 'unknown' + return { label: 'maintenance', value, color: COLORS.yellow } + }, + + 'score': async (pkgData: globalThis.Packument) => { + const score = await fetchNpmsScore(pkgData.name) + const value = score ? `${Math.round(score.final * 100)}%` : 'unknown' + return { label: 'score', value, color: COLORS.blue } + }, +} + +const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]]) + +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 = ` + + + + + + + + + + ${finalLabel} + ${finalValue} + + + `.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)}` + }, + }, +) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 65d354b1b..9254ec910 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -12,34 +12,115 @@ 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(' = { + 'version': 'version', + 'license': 'license', + 'size': 'install size', + 'downloads': 'downloads/mo', + 'downloads-day': 'downloads/day', + 'downloads-week': 'downloads/wk', + 'downloads-month': 'downloads/mo', + 'downloads-year': 'downloads/yr', + 'vulnerabilities': 'vulns', + 'dependencies': 'dependencies', + 'updated': 'updated', + 'engines': 'node', + 'types': 'types', + 'created': 'created', + 'maintainers': 'maintainers', + 'deprecated': 'status', + 'quality': 'quality', + 'popularity': 'popularity', + 'maintenance': 'maintenance', + 'score': 'score', + } + + const percentageTypes = new Set(['quality', 'popularity', 'maintenance', 'score']) + + for (const [type, expectedLabel] of Object.entries(badgeMap)) { + test.describe(`${type} badge`, () => { + test('renders correct label', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/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(expectedLabel) + }) + + test('scoped package renders successfully', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/@nuxt/kit`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + }) + + test('explicit version badge renders successfully', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/nuxt/v/3.12.0`) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + if (type === 'version') { + expect(body).toContain('3.12.0') + } + }) + + test('respects name=true parameter', async ({ page, baseURL }) => { + const packageName = 'nuxt' + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/${packageName}?name=true`) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(packageName) + expect(body).not.toContain(expectedLabel) + }) + + if (percentageTypes.has(type)) { + test('contains percentage value', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/vue`) + const { body } = await fetchBadge(page, url) + + expect(body).toMatch(/\d+%|unknown/) + }) + } + }) + } + + test.describe('specific scenarios', () => { + test('downloads-year handles large numbers', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/downloads-year/lodash') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('downloads/yr') + expect(body).not.toContain('NaN') + }) + + test('deprecated badge shows active for non-deprecated packages', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/deprecated/vue') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('active') + }) + }) + + test('custom color parameter is applied to SVG', async ({ page, baseURL }) => { + const customColor = 'ff69b4' + const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?color=${customColor}`) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(`fill="#${customColor}"`) }) - test('scoped package badge renders SVG', async ({ page, baseURL }) => { - const url = toLocalUrl(baseURL, '/api/registry/badge/@nuxt/kit') - const { response, body } = await fetchBadge(page, url) + test('invalid badge type defaults to version strategy', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/invalid-type/nuxt') + const { body } = await fetchBadge(page, url) - expect(response.status()).toBe(200) - expect(response.headers()['content-type']).toContain('image/svg+xml') - expect(body).toContain(' { - const url = toLocalUrl(baseURL, '/api/registry/badge/nuxt/v/3.12.0') - const { response, body } = await fetchBadge(page, url) + test('missing package returns 404', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/version/') + const { response } = await fetchBadge(page, url) - expect(response.status()).toBe(200) - expect(response.headers()['content-type']).toContain('image/svg+xml') - expect(body).toContain('