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 ` + + + + + + + + + + ${finalLabel} + ${finalValue} + + + `.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 ` + + + + + + + + + + + + + + + + ${finalLabel} + + ${finalValue} + + + `.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 = ` - - - - - - - - - - ${finalLabel} - ${finalValue} - - - `.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)