diff --git a/docs/app/components/BadgeGeneratorParameters.vue b/docs/app/components/BadgeGeneratorParameters.vue index aa1b931dd9..1fd4b0361f 100644 --- a/docs/app/components/BadgeGeneratorParameters.vue +++ b/docs/app/components/BadgeGeneratorParameters.vue @@ -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 diff --git a/docs/content/2.guide/6.badges.md b/docs/content/2.guide/6.badges.md index a5194a0f4a..f141465737 100644 --- a/docs/content/2.guide/6.badges.md +++ b/docs/content/2.guide/6.badges.md @@ -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` diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index 3ed6d2f079..d818e37825 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -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' @@ -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, '&') @@ -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) @@ -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 @@ -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 = { + '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 => { @@ -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 @@ -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, diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 820b77d4c7..89b79a5490 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -197,6 +197,79 @@ test.describe('badge API', () => { expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"') }) + test.describe('style=compact', () => { + function getSvgWidth(body: string): number { + const match = body.match(/]*\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(/]*\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)