Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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`
73 changes: 64 additions & 9 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 @@ -200,18 +211,28 @@ function measureShieldsTextLength(text: string): number {
return estimateTextWidth(text, 'shieldsio')
}

function renderDefaultBadgeSvg(params: {
interface BadgeRenderParams {
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 : measureDefaultTextWidth(finalLabel)
const rightWidth = measureDefaultTextWidth(finalValue, FALLBACK_VALUE_EXTRA_PADDING_X)
}

function renderGeistBadgeSvg(
params: BadgeRenderParams & { leftWidth: number; rightWidth: number },
): string {
const {
finalColor,
finalLabel,
finalLabelColor,
finalValue,
labelTextColor,
valueTextColor,
leftWidth,
rightWidth,
} = params
const totalWidth = leftWidth + rightWidth
const height = 20
const escapedLabel = escapeXML(finalLabel)
Expand All @@ -234,6 +255,20 @@ function renderDefaultBadgeSvg(params: {
`.trim()
}

function renderDefaultBadgeSvg(params: BadgeRenderParams): string {
const leftWidth =
params.finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(params.finalLabel)
const rightWidth = measureDefaultTextWidth(params.finalValue, FALLBACK_VALUE_EXTRA_PADDING_X)
return renderGeistBadgeSvg({ ...params, leftWidth, rightWidth })
}

function renderCompactBadgeSvg(params: BadgeRenderParams): string {
const leftWidth =
params.finalLabel.trim().length === 0 ? 0 : measureCompactTextWidth(params.finalLabel)
const rightWidth = measureCompactTextWidth(params.finalValue)
return renderGeistBadgeSvg({ ...params, leftWidth, rightWidth })
}

function renderShieldsBadgeSvg(params: {
finalColor: string
finalLabel: string
Expand Down Expand Up @@ -506,7 +541,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 +596,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 +613,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 / 🤖 Autofix code

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 / 🔠 Lint project

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