Skip to content

Commit 0cfffbd

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

2 files changed

Lines changed: 127 additions & 29 deletions

File tree

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

Lines changed: 113 additions & 29 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,119 @@ function getCanvasContext(): SKRSContext2D | null {
5861
return cachedCanvasContext
5962
}
6063

61-
function fallbackMeasureTextWidth(text: string): number {
64+
function OLD_measureTextWidth(text: string): number {
6265
return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2)
6366
}
6467

65-
function measureTextWidth(text: string): number {
68+
function measureText(text: string, font: string): number | null {
6669
const context = getCanvasContext()
6770

6871
if (context) {
69-
context.font = BADGE_FONT_SHORTHAND
72+
context.font = font
7073

7174
const measuredWidth = context.measureText(text).width
7275

73-
if (!Number.isNaN(measuredWidth)) {
74-
return Math.max(MIN_BADGE_TEXT_WIDTH, Math.ceil(measuredWidth) + BADGE_PADDING_X * 2)
76+
if (Number.isFinite(measuredWidth)) {
77+
return Math.ceil(measuredWidth)
7578
}
7679
}
7780

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

81179
function formatBytes(bytes: number): string {
@@ -284,6 +382,7 @@ const badgeStrategies = {
284382
}
285383

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

288387
export default defineCachedEventHandler(
289388
async event => {
@@ -309,6 +408,8 @@ export default defineCachedEventHandler(
309408
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
310409
const showName = queryParams.success && queryParams.output.name === 'true'
311410
const userLabel = queryParams.success ? queryParams.output.label : undefined
411+
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
412+
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
312413

313414
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
314415
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
@@ -325,29 +426,12 @@ export default defineCachedEventHandler(
325426
const rawColor = userColor ?? strategyResult.color
326427
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
327428

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()
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 })
351435

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