diff --git a/package.json b/package.json
index 804a4dcb3..73d59e6f5 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"@iconify-json/vscode-icons": "1.2.40",
"@intlify/shared": "11.2.8",
"@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3",
+ "@napi-rs/canvas": "0.1.92",
"@nuxt/a11y": "1.0.0-alpha.1",
"@nuxt/fonts": "0.13.0",
"@nuxt/scripts": "0.13.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd7800b3b..7f9666e25 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
'@lunariajs/core':
specifier: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3
version: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3
+ '@napi-rs/canvas':
+ specifier: 0.1.92
+ version: 0.1.92
'@nuxt/a11y':
specifier: 1.0.0-alpha.1
version: 1.0.0-alpha.1(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
@@ -1933,6 +1936,81 @@ packages:
'@cfworker/json-schema':
optional: true
+ '@napi-rs/canvas-android-arm64@0.1.92':
+ resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@0.1.92':
+ resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@0.1.92':
+ resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92':
+ resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.92':
+ resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.92':
+ resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.92':
+ resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.92':
+ resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.92':
+ resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.92':
+ resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.92':
+ resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@0.1.92':
+ resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==}
+ engines: {node: '>= 10'}
+
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -11526,6 +11604,53 @@ snapshots:
- hono
- supports-color
+ '@napi-rs/canvas-android-arm64@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.92':
+ optional: true
+
+ '@napi-rs/canvas@0.1.92':
+ optionalDependencies:
+ '@napi-rs/canvas-android-arm64': 0.1.92
+ '@napi-rs/canvas-darwin-arm64': 0.1.92
+ '@napi-rs/canvas-darwin-x64': 0.1.92
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.92
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.92
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.92
+ '@napi-rs/canvas-linux-x64-musl': 0.1.92
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.92
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.92
+
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts
index 6092913fe..de0403fcd 100644
--- a/server/api/registry/badge/[type]/[...pkg].get.ts
+++ b/server/api/registry/badge/[type]/[...pkg].get.ts
@@ -1,4 +1,5 @@
import * as v from 'valibot'
+import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
import { hash } from 'ohash'
import { createError, getRouterParam, getQuery, setHeader } from 'h3'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
@@ -34,15 +35,141 @@ const COLORS = {
white: '#ffffff',
}
-const DEFAULT_CHAR_WIDTH = 7
-const CHARS_WIDTH = {
- engines: 5.5,
+const CHAR_WIDTH = 7
+const SHIELDS_CHAR_WIDTH = 6
+
+const BADGE_PADDING_X = 8
+const MIN_BADGE_TEXT_WIDTH = 40
+const SHIELDS_LABEL_PADDING_X = 5
+
+const BADGE_FONT_SHORTHAND = 'normal normal 400 11px Geist, system-ui, -apple-system, sans-serif'
+const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu Sans, sans-serif'
+
+let cachedCanvasContext: SKRSContext2D | null | undefined
+
+function getCanvasContext(): SKRSContext2D | null {
+ if (cachedCanvasContext !== undefined) {
+ return cachedCanvasContext
+ }
+
+ try {
+ cachedCanvasContext = createCanvas(1, 1).getContext('2d')
+ } catch {
+ cachedCanvasContext = null
+ }
+
+ return cachedCanvasContext
}
-function measureTextWidth(text: string, charWidth?: number): number {
- charWidth ??= DEFAULT_CHAR_WIDTH
- const paddingX = 8
- return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
+function measureTextWidth(text: string, font: string): number | null {
+ const context = getCanvasContext()
+
+ if (context) {
+ context.font = font
+
+ const measuredWidth = context.measureText(text).width
+
+ if (!Number.isNaN(measuredWidth)) {
+ return Math.ceil(measuredWidth)
+ }
+ }
+
+ return null
+}
+
+function measureDefaultTextWidth(text: string): number {
+ const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)
+
+ if (measuredWidth !== null) {
+ return Math.max(MIN_BADGE_TEXT_WIDTH, measuredWidth + BADGE_PADDING_X * 2)
+ }
+
+ return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2)
+}
+
+function measureShieldsTextLength(text: string): number {
+ const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND)
+
+ if (measuredWidth !== null) {
+ return Math.max(1, measuredWidth)
+ }
+
+ return Math.max(1, Math.round(text.length * SHIELDS_CHAR_WIDTH))
+}
+
+function renderDefaultBadgeSvg(params: {
+ finalColor: string
+ finalLabel: string
+ finalLabelColor: string
+ finalValue: string
+}): string {
+ const { finalColor, finalLabel, finalLabelColor, finalValue } = params
+ const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel)
+ const rightWidth = measureDefaultTextWidth(finalValue)
+ const totalWidth = leftWidth + rightWidth
+ const height = 20
+
+ return `
+
+ `.trim()
+}
+
+function renderShieldsBadgeSvg(params: {
+ finalColor: string
+ finalLabel: string
+ finalLabelColor: string
+ finalValue: string
+}): string {
+ const { finalColor, finalLabel, finalLabelColor, finalValue } = params
+ const hasLabel = finalLabel.trim().length > 0
+
+ const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
+ const rightTextLength = measureShieldsTextLength(finalValue)
+ const leftWidth = hasLabel ? leftTextLength + SHIELDS_LABEL_PADDING_X * 2 : 0
+ const rightWidth = rightTextLength + SHIELDS_LABEL_PADDING_X * 2
+ const totalWidth = leftWidth + rightWidth
+ const height = 20
+ const title = `${finalLabel}: ${finalValue}`
+
+ const leftCenter = Math.round((leftWidth / 2) * 10)
+ const rightCenter = Math.round((leftWidth + rightWidth / 2) * 10)
+ const leftTextLengthAttr = leftTextLength * 10
+ const rightTextLengthAttr = rightTextLength * 10
+
+ return `
+
+ `.trim()
}
function formatBytes(bytes: number): string {
@@ -251,6 +378,7 @@ const badgeStrategies = {
}
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
+const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
export default defineCachedEventHandler(
async event => {
@@ -276,6 +404,8 @@ export default defineCachedEventHandler(
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
const showName = queryParams.success && queryParams.output.name === 'true'
const userLabel = queryParams.success ? queryParams.output.label : undefined
+ const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
+ const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
@@ -292,32 +422,12 @@ export default defineCachedEventHandler(
const rawColor = userColor ?? strategyResult.color
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
- const rawLabelColor = labelColor ?? '#0a0a0a'
- const finalLabelColor = rawLabelColor?.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+ const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
+ const rawLabelColor = labelColor ?? defaultLabelColor
+ const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
- const leftWidth = finalLabel.trim().length === 0 ? 0 : measureTextWidth(finalLabel)
- const rightWidth = measureTextWidth(
- finalValue,
- CHARS_WIDTH[strategyKey as keyof typeof CHARS_WIDTH],
- )
- const totalWidth = leftWidth + rightWidth
- const height = 20
-
- const svg = `
-
- `.trim()
+ const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
+ const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
setHeader(event, 'Content-Type', 'image/svg+xml')
setHeader(
diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts
index ff6f972d4..3fe9906a4 100644
--- a/test/e2e/badge.spec.ts
+++ b/test/e2e/badge.spec.ts
@@ -126,6 +126,20 @@ test.describe('badge API', () => {
expect(body).toContain(customLabel)
})
+ test('style=default keeps current badge renderer', async ({ page, baseURL }) => {
+ const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=default')
+ const { body } = await fetchBadge(page, url)
+
+ expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"')
+ })
+
+ test('style=shieldsio renders shields.io-like badge', async ({ page, baseURL }) => {
+ const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio')
+ const { body } = await fetchBadge(page, url)
+
+ expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"')
+ })
+
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)