Skip to content

Commit 21891e3

Browse files
committed
feat: add shields.io-style badges
1 parent 6dbcf06 commit 21891e3

2 files changed

Lines changed: 125 additions & 31 deletions

File tree

server/api/registry/badge/[type]/[...pkg].get.ts

Lines changed: 111 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ const COLORS = {
3636
}
3737

3838
const CHAR_WIDTH = 7
39+
const SHIELDS_CHAR_WIDTH = 6
3940

4041
const BADGE_PADDING_X = 8
4142
const MIN_BADGE_TEXT_WIDTH = 40
43+
const SHIELDS_LABEL_PADDING_X = 5
4244

4345
const BADGE_FONT_SHORTHAND = 'normal normal 400 11px Geist, system-ui, -apple-system, sans-serif'
46+
const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu Sans, sans-serif'
4447

4548
let cachedCanvasContext: SKRSContext2D | null | undefined
4649

@@ -58,24 +61,115 @@ function getCanvasContext(): SKRSContext2D | null {
5861
return cachedCanvasContext
5962
}
6063

61-
function fallbackMeasureTextWidth(text: string): number {
62-
return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2)
63-
}
64-
65-
function measureTextWidth(text: string): number {
64+
function measureTextWidth(text: string, font: string): number | null {
6665
const context = getCanvasContext()
6766

6867
if (context) {
69-
context.font = BADGE_FONT_SHORTHAND
68+
context.font = font
7069

7170
const measuredWidth = context.measureText(text).width
7271

7372
if (!Number.isNaN(measuredWidth)) {
74-
return Math.max(MIN_BADGE_TEXT_WIDTH, Math.ceil(measuredWidth) + BADGE_PADDING_X * 2)
73+
return Math.ceil(measuredWidth)
7574
}
7675
}
7776

78-
return fallbackMeasureTextWidth(text)
77+
return null
78+
}
79+
80+
function measureDefaultTextWidth(text: string): number {
81+
const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)
82+
83+
if (measuredWidth !== null) {
84+
return Math.max(MIN_BADGE_TEXT_WIDTH, measuredWidth + BADGE_PADDING_X * 2)
85+
}
86+
87+
return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2)
88+
}
89+
90+
function measureShieldsTextLength(text: string): number {
91+
const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND)
92+
93+
if (measuredWidth !== null) {
94+
return Math.max(1, measuredWidth)
95+
}
96+
97+
return Math.max(1, Math.round(text.length * SHIELDS_CHAR_WIDTH))
98+
}
99+
100+
function renderDefaultBadgeSvg(params: {
101+
finalColor: string
102+
finalLabel: string
103+
finalLabelColor: string
104+
finalValue: string
105+
}): string {
106+
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
107+
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel)
108+
const rightWidth = measureDefaultTextWidth(finalValue)
109+
const totalWidth = leftWidth + rightWidth
110+
const height = 20
111+
112+
return `
113+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${finalLabel}: ${finalValue}">
114+
<clipPath id="r">
115+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
116+
</clipPath>
117+
<g clip-path="url(#r)">
118+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
119+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
120+
</g>
121+
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
122+
<text x="${leftWidth / 2}" y="14" fill="#ffffff">${finalLabel}</text>
123+
<text x="${leftWidth + rightWidth / 2}" y="14" fill="#ffffff">${finalValue}</text>
124+
</g>
125+
</svg>
126+
`.trim()
127+
}
128+
129+
function renderShieldsBadgeSvg(params: {
130+
finalColor: string
131+
finalLabel: string
132+
finalLabelColor: string
133+
finalValue: string
134+
}): string {
135+
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
136+
const hasLabel = finalLabel.trim().length > 0
137+
138+
const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
139+
const rightTextLength = measureShieldsTextLength(finalValue)
140+
const leftWidth = hasLabel ? leftTextLength + SHIELDS_LABEL_PADDING_X * 2 : 0
141+
const rightWidth = rightTextLength + SHIELDS_LABEL_PADDING_X * 2
142+
const totalWidth = leftWidth + rightWidth
143+
const height = 20
144+
const title = `${finalLabel}: ${finalValue}`
145+
146+
const leftCenter = Math.round((leftWidth / 2) * 10)
147+
const rightCenter = Math.round((leftWidth + rightWidth / 2) * 10)
148+
const leftTextLengthAttr = leftTextLength * 10
149+
const rightTextLengthAttr = rightTextLength * 10
150+
151+
return `
152+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${title}">
153+
<linearGradient id="s" x2="0" y2="100%">
154+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
155+
<stop offset="1" stop-opacity=".1"/>
156+
</linearGradient>
157+
<clipPath id="r">
158+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
159+
</clipPath>
160+
<g clip-path="url(#r)">
161+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
162+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
163+
<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
164+
</g>
165+
<g fill="#fff" text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
166+
<text aria-hidden="true" x="${leftCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextLengthAttr}">${finalLabel}</text>
167+
<text x="${leftCenter}" y="140" transform="scale(.1)" fill="#fff" textLength="${leftTextLengthAttr}">${finalLabel}</text>
168+
<text aria-hidden="true" x="${rightCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextLengthAttr}">${finalValue}</text>
169+
<text x="${rightCenter}" y="140" transform="scale(.1)" fill="#fff" textLength="${rightTextLengthAttr}">${finalValue}</text>
170+
</g>
171+
</svg>
172+
`.trim()
79173
}
80174

81175
function formatBytes(bytes: number): string {
@@ -284,6 +378,7 @@ const badgeStrategies = {
284378
}
285379

286380
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
381+
const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
287382

288383
export default defineCachedEventHandler(
289384
async event => {
@@ -309,6 +404,8 @@ export default defineCachedEventHandler(
309404
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
310405
const showName = queryParams.success && queryParams.output.name === 'true'
311406
const userLabel = queryParams.success ? queryParams.output.label : undefined
407+
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
408+
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
312409

313410
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
314411
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
@@ -325,29 +422,12 @@ export default defineCachedEventHandler(
325422
const rawColor = userColor ?? strategyResult.color
326423
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
327424

328-
const rawLabelColor = labelColor ?? '#0a0a0a'
329-
const finalLabelColor = rawLabelColor?.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
330-
331-
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureTextWidth(finalLabel)
332-
const rightWidth = measureTextWidth(finalValue)
333-
const totalWidth = leftWidth + rightWidth
334-
const height = 20
335-
336-
const svg = `
337-
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${finalLabel}: ${finalValue}">
338-
<clipPath id="r">
339-
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
340-
</clipPath>
341-
<g clip-path="url(#r)">
342-
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
343-
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
344-
</g>
345-
<g text-anchor="middle" font-family="'Geist', system-ui, -apple-system, sans-serif" font-size="11">
346-
<text x="${leftWidth / 2}" y="14" fill="#ffffff">${finalLabel}</text>
347-
<text x="${leftWidth + rightWidth / 2}" y="14" fill="#ffffff">${finalValue}</text>
348-
</g>
349-
</svg>
350-
`.trim()
425+
const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
426+
const rawLabelColor = labelColor ?? defaultLabelColor
427+
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
428+
429+
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
430+
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
351431

352432
setHeader(event, 'Content-Type', 'image/svg+xml')
353433
setHeader(

test/e2e/badge.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ test.describe('badge API', () => {
126126
expect(body).toContain(customLabel)
127127
})
128128

129+
test('style=default keeps current badge renderer', async ({ page, baseURL }) => {
130+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=default')
131+
const { body } = await fetchBadge(page, url)
132+
133+
expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"')
134+
})
135+
136+
test('style=shieldsio renders shields.io-like badge', async ({ page, baseURL }) => {
137+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio')
138+
const { body } = await fetchBadge(page, url)
139+
140+
expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"')
141+
})
142+
129143
test('invalid badge type defaults to version strategy', async ({ page, baseURL }) => {
130144
const url = toLocalUrl(baseURL, '/api/registry/badge/invalid-type/nuxt')
131145
const { body } = await fetchBadge(page, url)

0 commit comments

Comments
 (0)