Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/app/components/BadgeGeneratorParameters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const badgeColor = useState('badge-color', () => '')
const usePkgName = useState('badge-use-name', () => false)
const badgeStyle = useState('badge-style', () => 'default')

const styles = ['default', 'shieldsio']
const styles = ['default', 'shieldsio', 'compact']

const validateHex = (hex: string) => {
if (!hex) return true
Expand Down
8 changes: 6 additions & 2 deletions docs/content/2.guide/6.badges.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ When set to `true`, this parameter replaces the static category label (like "ver

### `style`

Overrides the default badge appearance. Pass `shieldsio` to use the shields.io-compatible style.
Overrides the badge appearance.

- `default` — the standard npmx.dev look at 20px tall.
- `shieldsio` — the classic shields.io-compatible look at 20px tall, useful when you need the badge to sit alongside existing shields.io badges.
- `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.

- **Default**: `default`
- **Usage**: `?style=shieldsio`
- **Usage**: `?style=compact` or `?style=shieldsio`
71 changes: 68 additions & 3 deletions server/api/registry/badge/[type]/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const BADGE_PADDING_X = 8
const MIN_BADGE_TEXT_WIDTH = 40
const FALLBACK_VALUE_EXTRA_PADDING_X = 8
const SHIELDS_LABEL_PADDING_X = 5
const COMPACT_BADGE_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'
Expand Down Expand Up @@ -165,6 +166,16 @@ function measureDefaultTextWidth(text: string, fallbackExtraPadding = 0): number
)
}

function measureCompactTextWidth(text: string): number {
const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)

if (measuredWidth !== null) {
return measuredWidth + COMPACT_BADGE_PADDING_X * 2
}

return estimateTextWidth(text, 'default') + COMPACT_BADGE_PADDING_X * 2
}

function escapeXML(str: string): string {
return str
.replace(/&/g, '&')
Expand Down Expand Up @@ -234,6 +245,40 @@ function renderDefaultBadgeSvg(params: {
`.trim()
}

function renderCompactBadgeSvg(params: {
finalColor: string
finalLabel: string
finalLabelColor: string
finalValue: string
labelTextColor: string
valueTextColor: string
}): string {
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
params
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureCompactTextWidth(finalLabel)
const rightWidth = measureCompactTextWidth(finalValue)
const totalWidth = leftWidth + rightWidth
const height = 20
const escapedLabel = escapeXML(finalLabel)
const escapedValue = escapeXML(finalValue)

return `
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
<clipPath id="r">
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
</g>
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
<text x="${leftWidth / 2}" y="14" fill="${labelTextColor}">${escapedLabel}</text>
<text x="${leftWidth + rightWidth / 2}" y="14" fill="${valueTextColor}">${escapedValue}</text>
</g>
</svg>
`.trim()
}

function renderShieldsBadgeSvg(params: {
finalColor: string
finalLabel: string
Expand Down Expand Up @@ -506,7 +551,23 @@ const badgeStrategies = {
}

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

const BADGE_RENDERERS = {
default: renderDefaultBadgeSvg,
shieldsio: renderShieldsBadgeSvg,
compact: renderCompactBadgeSvg,
} as const

const COMPACT_LABEL_MAP: Record<string, string> = {
'install size': 'size',
'downloads/day': 'dl/day',
'downloads/wk': 'dl/wk',
'downloads/mo': 'dl/mo',
'downloads/yr': 'dl/yr',
'dependencies': 'deps',
'maintainers': 'maint',
}

export default defineCachedEventHandler(
async event => {
Expand Down Expand Up @@ -545,7 +606,11 @@ export default defineCachedEventHandler(
const pkgData = await fetchNpmPackage(packageName)
const strategyResult = await strategy(pkgData, requestedVersion)

const finalLabel = userLabel ? userLabel : showName ? packageName : strategyResult.label
const strategyLabel =
badgeStyle === 'compact'
? (COMPACT_LABEL_MAP[strategyResult.label] ?? strategyResult.label)
: strategyResult.label
const finalLabel = userLabel ? userLabel : showName ? packageName : strategyLabel
const finalValue = userValue ? userValue : strategyResult.value

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

const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
const renderFn = BADGE_RENDERERS[badgeStyle]
const svg = renderFn({
finalColor,
finalLabel,
Expand Down
73 changes: 73 additions & 0 deletions test/e2e/badge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,79 @@
expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"')
})

test.describe('style=compact', () => {
function getSvgWidth(body: string): number {

Check warning on line 201 in test/e2e/badge.spec.ts

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint-plugin-unicorn(consistent-function-scoping)

Function `getSvgWidth` does not capture any variables from its parent scope

Check warning on line 201 in test/e2e/badge.spec.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `getSvgWidth` does not capture any variables from its parent scope
const match = body.match(/<svg[^>]*\swidth="(\d+)"/)
return match ? Number(match[1]) : 0
}

test('uses the modern Geist renderer at the same 20px height as default', async ({
page,
baseURL,
}) => {
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=compact')
const { body } = await fetchBadge(page, url)

expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"')
expect(body).toMatch(/<svg[^>]*\sheight="20"/)
})

test('shortens long built-in labels', async ({ page, baseURL }) => {
const cases: Array<[string, string, string]> = [
['size', 'install size', 'size'],
['downloads', 'downloads/mo', 'dl/mo'],
['downloads-year', 'downloads/yr', 'dl/yr'],
['dependencies', 'dependencies', 'deps'],
['maintainers', 'maintainers', 'maint'],
]
for (const [type, fullLabel, shortLabel] of cases) {
const url = toLocalUrl(baseURL, `/api/registry/badge/${type}/nuxt?style=compact`)
const { body } = await fetchBadge(page, url)
expect(body, `${type} should show ${shortLabel}`).toContain(`>${shortLabel}<`)
expect(body, `${type} should not show ${fullLabel}`).not.toContain(`>${fullLabel}<`)
}
})

test('produces a narrower badge than the default style for shortened labels', async ({
page,
baseURL,
}) => {
const defaultUrl = toLocalUrl(baseURL, '/api/registry/badge/dependencies/nuxt?style=default')
const compactUrl = toLocalUrl(baseURL, '/api/registry/badge/dependencies/nuxt?style=compact')
const { body: defaultBody } = await fetchBadge(page, defaultUrl)
const { body: compactBody } = await fetchBadge(page, compactUrl)

expect(getSvgWidth(compactBody)).toBeGreaterThan(0)
expect(getSvgWidth(compactBody)).toBeLessThan(getSvgWidth(defaultBody))
})

test('does not trim a user-supplied label', async ({ page, baseURL }) => {
const url = toLocalUrl(
baseURL,
'/api/registry/badge/dependencies/nuxt?style=compact&label=my-deps',
)
const { body } = await fetchBadge(page, url)

expect(body).toContain('>my-deps<')
expect(body).not.toContain('>deps<')
})

test('uses the package name when name=true instead of the trimmed label', async ({
page,
baseURL,
}) => {
const url = toLocalUrl(
baseURL,
'/api/registry/badge/dependencies/nuxt?style=compact&name=true',
)
const { body } = await fetchBadge(page, url)

expect(body).toContain('>nuxt<')
expect(body).not.toContain('>deps<')
expect(body).not.toContain('>dependencies<')
})
})

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)
Expand Down
Loading