diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts
index 66f8d2d2e..d2d7fb6d1 100644
--- a/server/api/registry/badge/[type]/[...pkg].get.ts
+++ b/server/api/registry/badge/[type]/[...pkg].get.ts
@@ -36,11 +36,14 @@ const COLORS = {
}
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
@@ -58,24 +61,115 @@ function getCanvasContext(): SKRSContext2D | null {
return cachedCanvasContext
}
-function fallbackMeasureTextWidth(text: string): number {
- return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2)
-}
-
-function measureTextWidth(text: string): number {
+function measureTextWidth(text: string, font: string): number | null {
const context = getCanvasContext()
if (context) {
- context.font = BADGE_FONT_SHORTHAND
+ context.font = font
const measuredWidth = context.measureText(text).width
if (!Number.isNaN(measuredWidth)) {
- return Math.max(MIN_BADGE_TEXT_WIDTH, Math.ceil(measuredWidth) + BADGE_PADDING_X * 2)
+ return Math.ceil(measuredWidth)
}
}
- return fallbackMeasureTextWidth(text)
+ 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 {
@@ -288,6 +382,7 @@ const badgeStrategies = {
}
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
+const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
export default defineCachedEventHandler(
async event => {
@@ -313,6 +408,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'
@@ -329,29 +426,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 leftWidth = finalLabel.trim().length === 0 ? 0 : measureTextWidth(finalLabel)
- const rightWidth = measureTextWidth(finalValue)
- const totalWidth = leftWidth + rightWidth
- const height = 20
-
- const svg = `
-
- `.trim()
+ const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
+ const rawLabelColor = labelColor ?? defaultLabelColor
+ const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+
+ 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 f820b3b21..ffd258cdd 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)