Skip to content

Commit 24a2c1a

Browse files
authored
feat: add shields.io-style badges (#1487)
1 parent 93ee4b1 commit 24a2c1a

File tree

2 files changed

+125
-31
lines changed

2 files changed

+125
-31
lines changed

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 {
@@ -288,6 +382,7 @@ const badgeStrategies = {
288382
}
289383

290384
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
385+
const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
291386

292387
export default defineCachedEventHandler(
293388
async event => {
@@ -313,6 +408,8 @@ export default defineCachedEventHandler(
313408
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
314409
const showName = queryParams.success && queryParams.output.name === 'true'
315410
const userLabel = queryParams.success ? queryParams.output.label : undefined
411+
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
412+
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
316413

317414
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
318415
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
@@ -329,29 +426,12 @@ export default defineCachedEventHandler(
329426
const rawColor = userColor ?? strategyResult.color
330427
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
331428

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

356436
setHeader(event, 'Content-Type', 'image/svg+xml')
357437
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)