Skip to content

Commit fff5849

Browse files
committed
feat(badges): add ?style=compact variant
fixes #2509
1 parent b1684b0 commit fff5849

4 files changed

Lines changed: 148 additions & 6 deletions

File tree

docs/app/components/BadgeGeneratorParameters.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const badgeColor = useState('badge-color', () => '')
1414
const usePkgName = useState('badge-use-name', () => false)
1515
const badgeStyle = useState('badge-style', () => 'default')
1616
17-
const styles = ['default', 'shieldsio']
17+
const styles = ['default', 'shieldsio', 'compact']
1818
1919
const validateHex = (hex: string) => {
2020
if (!hex) return true

docs/content/2.guide/6.badges.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ When set to `true`, this parameter replaces the static category label (like "ver
114114

115115
### `style`
116116

117-
Overrides the default badge appearance. Pass `shieldsio` to use the shields.io-compatible style.
117+
Overrides the badge appearance.
118+
119+
- `default` — the standard npmx.dev look at 20px tall.
120+
- `shieldsio` — the classic shields.io-compatible look at 20px tall, useful when you need the badge to sit alongside existing shields.io badges.
121+
- `compact` — the same modern look and 20px height as `default` but with tight 5px text padding and no enforced minimum side width. Long built-in labels are also shortened (e.g. `install size``size`, `downloads/mo``dl/mo`, `dependencies``deps`, `maintainers``maint`) so the badge can take up roughly 20–50% less horizontal space in READMEs. Pass an explicit `label` or `name=true` to opt out of the shortening.
118122

119123
- **Default**: `default`
120-
- **Usage**: `?style=shieldsio`
124+
- **Usage**: `?style=compact` or `?style=shieldsio`

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

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const BADGE_PADDING_X = 8
4545
const MIN_BADGE_TEXT_WIDTH = 40
4646
const FALLBACK_VALUE_EXTRA_PADDING_X = 8
4747
const SHIELDS_LABEL_PADDING_X = 5
48+
const COMPACT_BADGE_PADDING_X = 5
4849

4950
const BADGE_FONT_SHORTHAND = 'normal normal 400 11px Geist, system-ui, -apple-system, sans-serif'
5051
const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu Sans, sans-serif'
@@ -165,6 +166,16 @@ function measureDefaultTextWidth(text: string, fallbackExtraPadding = 0): number
165166
)
166167
}
167168

169+
function measureCompactTextWidth(text: string): number {
170+
const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)
171+
172+
if (measuredWidth !== null) {
173+
return measuredWidth + COMPACT_BADGE_PADDING_X * 2
174+
}
175+
176+
return estimateTextWidth(text, 'default') + COMPACT_BADGE_PADDING_X * 2
177+
}
178+
168179
function escapeXML(str: string): string {
169180
return str
170181
.replace(/&/g, '&')
@@ -234,6 +245,40 @@ function renderDefaultBadgeSvg(params: {
234245
`.trim()
235246
}
236247

248+
function renderCompactBadgeSvg(params: {
249+
finalColor: string
250+
finalLabel: string
251+
finalLabelColor: string
252+
finalValue: string
253+
labelTextColor: string
254+
valueTextColor: string
255+
}): string {
256+
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
257+
params
258+
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureCompactTextWidth(finalLabel)
259+
const rightWidth = measureCompactTextWidth(finalValue)
260+
const totalWidth = leftWidth + rightWidth
261+
const height = 20
262+
const escapedLabel = escapeXML(finalLabel)
263+
const escapedValue = escapeXML(finalValue)
264+
265+
return `
266+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
267+
<clipPath id="r">
268+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
269+
</clipPath>
270+
<g clip-path="url(#r)">
271+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
272+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
273+
</g>
274+
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
275+
<text x="${leftWidth / 2}" y="14" fill="${labelTextColor}">${escapedLabel}</text>
276+
<text x="${leftWidth + rightWidth / 2}" y="14" fill="${valueTextColor}">${escapedValue}</text>
277+
</g>
278+
</svg>
279+
`.trim()
280+
}
281+
237282
function renderShieldsBadgeSvg(params: {
238283
finalColor: string
239284
finalLabel: string
@@ -506,7 +551,23 @@ const badgeStrategies = {
506551
}
507552

508553
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
509-
const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
554+
const BadgeStyleSchema = v.picklist(['default', 'shieldsio', 'compact'])
555+
556+
const BADGE_RENDERERS = {
557+
default: renderDefaultBadgeSvg,
558+
shieldsio: renderShieldsBadgeSvg,
559+
compact: renderCompactBadgeSvg,
560+
} as const
561+
562+
const COMPACT_LABEL_MAP: Record<string, string> = {
563+
'install size': 'size',
564+
'downloads/day': 'dl/day',
565+
'downloads/wk': 'dl/wk',
566+
'downloads/mo': 'dl/mo',
567+
'downloads/yr': 'dl/yr',
568+
'dependencies': 'deps',
569+
'maintainers': 'maint',
570+
}
510571

511572
export default defineCachedEventHandler(
512573
async event => {
@@ -545,7 +606,11 @@ export default defineCachedEventHandler(
545606
const pkgData = await fetchNpmPackage(packageName)
546607
const strategyResult = await strategy(pkgData, requestedVersion)
547608

548-
const finalLabel = userLabel ? userLabel : showName ? packageName : strategyResult.label
609+
const strategyLabel =
610+
badgeStyle === 'compact'
611+
? (COMPACT_LABEL_MAP[strategyResult.label] ?? strategyResult.label)
612+
: strategyResult.label
613+
const finalLabel = userLabel ? userLabel : showName ? packageName : strategyLabel
549614
const finalValue = userValue ? userValue : strategyResult.value
550615

551616
const rawColor = userColor ?? strategyResult.color
@@ -558,7 +623,7 @@ export default defineCachedEventHandler(
558623
const labelTextColor = getContrastTextColor(finalLabelColor)
559624
const valueTextColor = getContrastTextColor(finalColor)
560625

561-
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
626+
const renderFn = BADGE_RENDERERS[badgeStyle]
562627
const svg = renderFn({
563628
finalColor,
564629
finalLabel,

test/e2e/badge.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,79 @@ test.describe('badge API', () => {
197197
expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"')
198198
})
199199

200+
test.describe('style=compact', () => {
201+
function getSvgWidth(body: string): number {
202+
const match = body.match(/<svg[^>]*\swidth="(\d+)"/)
203+
return match ? Number(match[1]) : 0
204+
}
205+
206+
test('uses the modern Geist renderer at the same 20px height as default', async ({
207+
page,
208+
baseURL,
209+
}) => {
210+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=compact')
211+
const { body } = await fetchBadge(page, url)
212+
213+
expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"')
214+
expect(body).toMatch(/<svg[^>]*\sheight="20"/)
215+
})
216+
217+
test('shortens long built-in labels', async ({ page, baseURL }) => {
218+
const cases: Array<[string, string, string]> = [
219+
['size', 'install size', 'size'],
220+
['downloads', 'downloads/mo', 'dl/mo'],
221+
['downloads-year', 'downloads/yr', 'dl/yr'],
222+
['dependencies', 'dependencies', 'deps'],
223+
['maintainers', 'maintainers', 'maint'],
224+
]
225+
for (const [type, fullLabel, shortLabel] of cases) {
226+
const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/nuxt?style=compact`)
227+
const { body } = await fetchBadge(page, url)
228+
expect(body, `${type} should show ${shortLabel}`).toContain(`>${shortLabel}<`)
229+
expect(body, `${type} should not show ${fullLabel}`).not.toContain(`>${fullLabel}<`)
230+
}
231+
})
232+
233+
test('produces a narrower badge than the default style for shortened labels', async ({
234+
page,
235+
baseURL,
236+
}) => {
237+
const defaultUrl = toLocalUrl(baseURL, '/api/registry/badge/dependencies/nuxt?style=default')
238+
const compactUrl = toLocalUrl(baseURL, '/api/registry/badge/dependencies/nuxt?style=compact')
239+
const { body: defaultBody } = await fetchBadge(page, defaultUrl)
240+
const { body: compactBody } = await fetchBadge(page, compactUrl)
241+
242+
expect(getSvgWidth(compactBody)).toBeGreaterThan(0)
243+
expect(getSvgWidth(compactBody)).toBeLessThan(getSvgWidth(defaultBody))
244+
})
245+
246+
test('does not trim a user-supplied label', async ({ page, baseURL }) => {
247+
const url = toLocalUrl(
248+
baseURL,
249+
'/api/registry/badge/dependencies/nuxt?style=compact&label=my-deps',
250+
)
251+
const { body } = await fetchBadge(page, url)
252+
253+
expect(body).toContain('>my-deps<')
254+
expect(body).not.toContain('>deps<')
255+
})
256+
257+
test('uses the package name when name=true instead of the trimmed label', async ({
258+
page,
259+
baseURL,
260+
}) => {
261+
const url = toLocalUrl(
262+
baseURL,
263+
'/api/registry/badge/dependencies/nuxt?style=compact&name=true',
264+
)
265+
const { body } = await fetchBadge(page, url)
266+
267+
expect(body).toContain('>nuxt<')
268+
expect(body).not.toContain('>deps<')
269+
expect(body).not.toContain('>dependencies<')
270+
})
271+
})
272+
200273
test('invalid badge type defaults to version strategy', async ({ page, baseURL }) => {
201274
const url = toLocalUrl(baseURL, '/api/registry/badge/invalid-type/nuxt')
202275
const { body } = await fetchBadge(page, url)

0 commit comments

Comments
 (0)