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..8a8bbb96b2 100644
--- a/docs/content/2.guide/6.badges.md
+++ b/docs/content/2.guide/6.badges.md
@@ -54,6 +54,72 @@ npmx.dev offers many different SVG badges with stats about any package via its A
[](https://npmx.dev/package/react)
```
+## Compare Badges
+
+Compare badges show how a stat differs between two pinned package versions using a `from → to` value. They support both **same-package** comparisons (e.g. how `nuxt` changed between `2.18.1` and `4.3.1`) and **cross-package** comparisons (e.g. `nuxt` vs. `next`). They share the same look, fonts and styles as the regular badges and accept the same customization parameters.
+
+### URL pattern
+
+**Same-package** (shorthand version range):
+
+```
+/api/registry/badge/compare/{type}/{package}/v/{from}...{to}
+```
+
+The version range uses the same triple-dot (`...`) syntax as the rest of the npmx.dev compare API.
+
+**Cross-package** (`vs` separator between two pinned `pkg@version` specs):
+
+```
+/api/registry/badge/compare/{type}/{pkgA}/v/{verA}/vs/{pkgB}/v/{verB}
+```
+
+In all forms, both versions must already exist on npm — unknown versions return `404`.
+
+The badge stays visually compact: only the raw `from → to` values are rendered, so a cross-package size compare reads `52.7 KB → 200 KB` rather than carrying the package names inline. The package context lives in the URL and the SVG `aria-label`. With `name=true` the label switches from the strategy name to `{pkgA} → {pkgB}`, mirroring the regular single-package `name=true` behavior.
+
+Because the data for two pinned versions is immutable, compare badges are cached for one year (vs. one hour for the regular badges).
+
+### Available Compare Badge Types
+
+- **version**: `v{from} → v{to}`. Always blue.
+- **size**: install size delta (Bundlephobia, falls back to packument `dist.unpackedSize`). Color is **green** when the size shrunk by ≥5%, **red** when it grew by ≥5%, **slate** otherwise.
+- **dependencies**: total runtime dependency count delta. Color follows the same direction logic as `size` (more deps = red, fewer = green).
+- **license**: `{from} → {to}` license. **Green** when the license is unchanged across versions, **yellow** when it changed.
+- **engines**: supported `engines.node` range. **Slate** when the supported range is unchanged, **yellow** when it changed.
+
+Compare-incompatible badge types (`name`, `created`, `updated`, `downloads*`, `maintainers`, `likes`, `types`, `vulnerabilities`, `deprecated`) are not exposed under `/badge/compare/...` and return `404`.
+
+### Examples
+
+```md
+# Same-package version delta
+
+[](https://npmx.dev/package/nuxt)
+
+# Same-package install size delta (directional color)
+
+[](https://npmx.dev/package/nuxt)
+
+# Dependency count delta on a scoped package
+
+[](https://npmx.dev/package/@nuxt/kit)
+
+# Cross-package install size comparison
+
+[](https://npmx.dev/package/nuxt)
+
+# Cross-package dependencies comparison
+
+[](https://npmx.dev/package/nuxt)
+
+# Compact compare badge (e.g. for tight READMEs)
+
+[](https://npmx.dev/package/nuxt)
+```
+
+All [Customization Parameters](#customization-parameters) below — `label`, `value`, `color`, `labelColor`, `name`, `style` — work identically on compare badges. A user-supplied `color` overrides the directional color.
+
## Customization Parameters
You can further customize your badges by appending query parameters to the badge URL.
@@ -114,7 +180,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/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts
index 0ecd2f40d5..2a9500e995 100644
--- a/modules/runtime/server/cache.ts
+++ b/modules/runtime/server/cache.ts
@@ -122,14 +122,24 @@ function getMockForUrl(url: string): MockResult | null {
return { data: null }
}
- // Bundlephobia API - return mock size data
+ // Bundlephobia API - return mock size data.
+ // When a version is supplied, the size is derived deterministically from the
+ // version string so that compare-badge tests can verify per-version size
+ // deltas. Without a version we fall back to the legacy fixture value.
if (host === 'bundlephobia.com' && pathname === '/api/size') {
const packageSpec = searchParams.get('package')
if (packageSpec) {
+ const parsed = parseScopedPackageWithVersion(packageSpec)
+ let size = 12345
+ if (parsed.version) {
+ let h = 0
+ for (const ch of parsed.version) h = (h * 31 + ch.charCodeAt(0)) >>> 0
+ size = 10000 + (h % 50000)
+ }
return {
data: {
- name: packageSpec.split('@')[0],
- size: 12345,
+ name: parsed.name,
+ size,
gzip: 4567,
dependencyCount: 3,
},
diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts
index 3ed6d2f079..d821a11a69 100644
--- a/server/api/registry/badge/[type]/[...pkg].get.ts
+++ b/server/api/registry/badge/[type]/[...pkg].get.ts
@@ -1,5 +1,4 @@
import * as v from 'valibot'
-import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
import { hash } from 'ohash'
import { createError, getRouterParam, getQuery, setHeader } from 'h3'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
@@ -8,306 +7,21 @@ import { fetchNpmPackage } from '#server/utils/npm'
import { assertValidPackageName } from '#shared/utils/npm'
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
import { handleApiError } from '#server/utils/error-handler'
+import {
+ BADGE_COLORS,
+ BADGE_RENDERERS,
+ BadgeQuerySchema,
+ BadgeStyleSchema,
+ formatBadgeBytes,
+ formatBadgeDate,
+ formatBadgeNumber,
+ resolveBadgeAppearance,
+} from '#server/utils/badges/render'
const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'
const OSV_QUERY_API = 'https://api.osv.dev/v1/query'
const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
-const SafeStringSchema = v.pipe(v.string(), v.regex(/^[^<>"&]*$/, 'Invalid characters'))
-const SafeColorSchema = v.pipe(
- v.string(),
- v.transform(value => (value.startsWith('#') ? value : `#${value}`)),
- v.hexColor(),
-)
-
-const QUERY_SCHEMA = v.object({
- name: v.optional(v.string()),
- label: v.optional(SafeStringSchema),
- value: v.optional(SafeStringSchema),
- color: v.optional(SafeColorSchema),
- labelColor: v.optional(SafeColorSchema),
-})
-
-const COLORS = {
- blue: '#3b82f6',
- green: '#22c55e',
- purple: '#a855f7',
- orange: '#f97316',
- red: '#ef4444',
- cyan: '#06b6d4',
- slate: '#64748b',
- yellow: '#eab308',
- black: '#0a0a0a',
- white: '#ffffff',
-}
-
-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 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'
-
-let cachedCanvasContext: SKRSContext2D | null | undefined
-
-const NARROW_CHARS = new Set([' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', ':', ';', '|'])
-const MEDIUM_CHARS = new Set([
- '#',
- '$',
- '+',
- '/',
- '<',
- '=',
- '>',
- '?',
- '@',
- '[',
- '\\',
- ']',
- '^',
- '_',
- '`',
- '{',
- '}',
- '~',
-])
-
-const FALLBACK_WIDTHS = {
- default: {
- narrow: 3,
- medium: 5,
- digit: 6,
- uppercase: 7,
- other: 6,
- },
- shieldsio: {
- narrow: 3,
- medium: 5,
- digit: 6,
- uppercase: 7,
- other: 5.5,
- },
-} as const
-
-function estimateTextWidth(text: string, fallbackFont: 'default' | 'shieldsio'): number {
- // Heuristic coefficients tuned to keep fallback rendering close to canvas metrics.
- const widths = FALLBACK_WIDTHS[fallbackFont]
- let totalWidth = 0
-
- for (const character of text) {
- if (NARROW_CHARS.has(character)) {
- totalWidth += widths.narrow
- continue
- }
-
- if (MEDIUM_CHARS.has(character)) {
- totalWidth += widths.medium
- continue
- }
-
- if (/\d/.test(character)) {
- totalWidth += widths.digit
- continue
- }
-
- if (/[A-Z]/.test(character)) {
- totalWidth += widths.uppercase
- continue
- }
-
- totalWidth += widths.other
- }
-
- return Math.max(1, Math.round(totalWidth))
-}
-
-function getCanvasContext(): SKRSContext2D | null {
- if (cachedCanvasContext !== undefined) {
- return cachedCanvasContext
- }
-
- try {
- cachedCanvasContext = createCanvas(1, 1).getContext('2d')
- } catch {
- cachedCanvasContext = null
- }
-
- return cachedCanvasContext
-}
-
-function measureTextWidth(text: string, font: string): number | null {
- const context = getCanvasContext()
-
- if (context) {
- context.font = font
-
- const measuredWidth = context.measureText(text).width
-
- if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
- return Math.ceil(measuredWidth)
- }
- }
-
- return null
-}
-
-function measureDefaultTextWidth(text: string, fallbackExtraPadding = 0): number {
- const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)
-
- if (measuredWidth !== null) {
- return Math.max(MIN_BADGE_TEXT_WIDTH, measuredWidth + BADGE_PADDING_X * 2)
- }
-
- return Math.max(
- MIN_BADGE_TEXT_WIDTH,
- estimateTextWidth(text, 'default') + BADGE_PADDING_X * 2 + fallbackExtraPadding,
- )
-}
-
-function escapeXML(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
-function toLinear(c: number): number {
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
-}
-
-function getContrastTextColor(bgHex: string): string {
- let clean = bgHex.replace('#', '')
- if (clean.length === 3)
- clean = clean[0]! + clean[0]! + clean[1]! + clean[1]! + clean[2]! + clean[2]!
- if (!/^[0-9a-f]{6}$/i.test(clean)) return '#ffffff'
- const r = parseInt(clean.slice(0, 2), 16) / 255
- const g = parseInt(clean.slice(2, 4), 16) / 255
- const b = parseInt(clean.slice(4, 6), 16) / 255
- const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
- // threshold where contrast ratio with white equals contrast ratio with black
- return luminance > 0.179 ? '#000000' : '#ffffff'
-}
-
-function measureShieldsTextLength(text: string): number {
- const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND)
-
- if (measuredWidth !== null) {
- return Math.max(1, measuredWidth)
- }
-
- return estimateTextWidth(text, 'shieldsio')
-}
-
-function renderDefaultBadgeSvg(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 : measureDefaultTextWidth(finalLabel)
- const rightWidth = measureDefaultTextWidth(finalValue, FALLBACK_VALUE_EXTRA_PADDING_X)
- const totalWidth = leftWidth + rightWidth
- const height = 20
- const escapedLabel = escapeXML(finalLabel)
- const escapedValue = escapeXML(finalValue)
-
- return `
-
- `.trim()
-}
-
-function renderShieldsBadgeSvg(params: {
- finalColor: string
- finalLabel: string
- finalLabelColor: string
- finalValue: string
- labelTextColor: string
- valueTextColor: string
-}): string {
- const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
- params
- const hasLabel = finalLabel.trim().length > 0
-
- const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
- const rightTextLength = measureShieldsTextLength(finalValue)
- const leftWidth = hasLabel ? leftTextLength + SHIELDS_LABEL_PADDING_X * 2 : 0
- const rightWidth = rightTextLength + SHIELDS_LABEL_PADDING_X * 2
- const totalWidth = leftWidth + rightWidth
- const height = 20
- const escapedLabel = escapeXML(finalLabel)
- const escapedValue = escapeXML(finalValue)
- const title = `${escapedLabel}: ${escapedValue}`
-
- const leftCenter = Math.round((leftWidth / 2) * 10)
- const rightCenter = Math.round((leftWidth + rightWidth / 2) * 10)
- const leftTextLengthAttr = leftTextLength * 10
- const rightTextLengthAttr = rightTextLength * 10
-
- return `
-
- `.trim()
-}
-
-function formatBytes(bytes: number): string {
- if (!+bytes) return '0 B'
- const k = 1024
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
- return `${value} ${sizes[i]}`
-}
-
-function formatNumber(num: number): string {
- return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(
- num,
- )
-}
-
-function formatDate(dateString: string): string {
- return new Date(dateString).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })
-}
-
function getLatestVersion(pkgData: globalThis.Packument): string | undefined {
return pkgData['dist-tags']?.latest
}
@@ -353,7 +67,7 @@ async function fetchInstallSize(packageName: string, version: string): Promise {
- return { label: 'npm', value: pkgData.name, color: COLORS.slate }
+ return { label: 'npm', value: pkgData.name, color: BADGE_COLORS.slate }
},
'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
@@ -361,7 +75,7 @@ const badgeStrategies = {
return {
label: 'version',
value: version === 'unknown' ? version : `v${version}`,
- color: COLORS.blue,
+ color: BADGE_COLORS.blue,
}
},
@@ -369,7 +83,7 @@ const badgeStrategies = {
const latest = getLatestVersion(pkgData)
const versionData = latest ? pkgData.versions?.[latest] : undefined
const value = versionData?.license ?? 'unknown'
- return { label: 'license', value, color: COLORS.green }
+ return { label: 'license', value, color: BADGE_COLORS.green }
},
'size': async (pkgData: globalThis.Packument) => {
@@ -380,39 +94,39 @@ const badgeStrategies = {
const installSize = await fetchInstallSize(pkgData.name, latest)
if (installSize !== null) bytes = installSize
}
- return { label: 'install size', value: formatBytes(bytes), color: COLORS.purple }
+ return { label: 'install size', value: formatBadgeBytes(bytes), color: BADGE_COLORS.purple }
},
'downloads': async (pkgData: globalThis.Packument) => {
const count = await fetchDownloads(pkgData.name, 'last-month')
- return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
+ return { label: 'downloads/mo', value: formatBadgeNumber(count), color: BADGE_COLORS.orange }
},
'downloads-day': async (pkgData: globalThis.Packument) => {
const count = await fetchDownloads(pkgData.name, 'last-day')
- return { label: 'downloads/day', value: formatNumber(count), color: COLORS.orange }
+ return { label: 'downloads/day', value: formatBadgeNumber(count), color: BADGE_COLORS.orange }
},
'downloads-week': async (pkgData: globalThis.Packument) => {
const count = await fetchDownloads(pkgData.name, 'last-week')
- return { label: 'downloads/wk', value: formatNumber(count), color: COLORS.orange }
+ return { label: 'downloads/wk', value: formatBadgeNumber(count), color: BADGE_COLORS.orange }
},
'downloads-month': async (pkgData: globalThis.Packument) => {
const count = await fetchDownloads(pkgData.name, 'last-month')
- return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
+ return { label: 'downloads/mo', value: formatBadgeNumber(count), color: BADGE_COLORS.orange }
},
'downloads-year': async (pkgData: globalThis.Packument) => {
const count = await fetchDownloads(pkgData.name, 'last-year')
- return { label: 'downloads/yr', value: formatNumber(count), color: COLORS.orange }
+ return { label: 'downloads/yr', value: formatBadgeNumber(count), color: BADGE_COLORS.orange }
},
'vulnerabilities': async (pkgData: globalThis.Packument) => {
const latest = getLatestVersion(pkgData)
const count = latest ? await fetchVulnerabilities(pkgData.name, latest) : 0
const isSafe = count === 0
- const color = isSafe ? COLORS.green : COLORS.red
+ const color = isSafe ? BADGE_COLORS.green : BADGE_COLORS.red
return { label: 'vulns', value: String(count), color }
},
@@ -420,23 +134,23 @@ const badgeStrategies = {
const latest = getLatestVersion(pkgData)
const versionData = latest ? pkgData.versions?.[latest] : undefined
const count = Object.keys(versionData?.dependencies ?? {}).length
- return { label: 'dependencies', value: String(count), color: COLORS.cyan }
+ return { label: 'dependencies', value: String(count), color: BADGE_COLORS.cyan }
},
'created': async (pkgData: globalThis.Packument) => {
const dateStr = pkgData.time?.created ?? pkgData.time?.modified
- return { label: 'created', value: formatDate(dateStr), color: COLORS.slate }
+ return { label: 'created', value: formatBadgeDate(dateStr), color: BADGE_COLORS.slate }
},
'updated': async (pkgData: globalThis.Packument) => {
const dateStr = pkgData.time?.modified ?? pkgData.time?.created ?? new Date().toISOString()
- return { label: 'updated', value: formatDate(dateStr), color: COLORS.slate }
+ return { label: 'updated', value: formatBadgeDate(dateStr), color: BADGE_COLORS.slate }
},
'engines': async (pkgData: globalThis.Packument) => {
const latest = getLatestVersion(pkgData)
const nodeVersion = (latest && pkgData.versions?.[latest]?.engines?.node) ?? '*'
- return { label: 'node', value: nodeVersion, color: COLORS.yellow }
+ return { label: 'node', value: nodeVersion, color: BADGE_COLORS.yellow }
},
'types': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
@@ -444,7 +158,7 @@ const badgeStrategies = {
const versionData = targetVersion ? pkgData.versions?.[targetVersion] : undefined
if (versionData && hasBuiltInTypes(versionData)) {
- return { label: 'types', value: 'included', color: COLORS.blue }
+ return { label: 'types', value: 'included', color: BADGE_COLORS.blue }
}
const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles(
@@ -460,22 +174,22 @@ const badgeStrategies = {
switch (typesStatus.kind) {
case 'included':
value = 'included'
- color = COLORS.blue
+ color = BADGE_COLORS.blue
break
case '@types':
value = '@types'
- color = COLORS.purple
+ color = BADGE_COLORS.purple
if (typesStatus.deprecated) {
value += ' (deprecated)'
- color = COLORS.red
+ color = BADGE_COLORS.red
}
break
case 'none':
default:
value = 'missing'
- color = COLORS.slate
+ color = BADGE_COLORS.slate
break
}
@@ -484,7 +198,7 @@ const badgeStrategies = {
'maintainers': async (pkgData: globalThis.Packument) => {
const count = pkgData.maintainers?.length ?? 0
- return { label: 'maintainers', value: String(count), color: COLORS.cyan }
+ return { label: 'maintainers', value: String(count), color: BADGE_COLORS.cyan }
},
'deprecated': async (pkgData: globalThis.Packument) => {
@@ -493,7 +207,7 @@ const badgeStrategies = {
return {
label: 'status',
value: isDeprecated ? 'deprecated' : 'active',
- color: isDeprecated ? COLORS.red : COLORS.green,
+ color: isDeprecated ? BADGE_COLORS.red : BADGE_COLORS.green,
}
},
@@ -501,12 +215,11 @@ const badgeStrategies = {
const likesUtil = new PackageLikesUtils()
const { totalLikes } = await likesUtil.getLikes(pkgData.name)
- return { label: 'likes', value: String(totalLikes ?? 0), color: COLORS.red }
+ return { label: 'likes', value: String(totalLikes ?? 0), color: BADGE_COLORS.red }
},
}
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
-const BadgeStyleSchema = v.picklist(['default', 'shieldsio'])
export default defineCachedEventHandler(
async event => {
@@ -527,9 +240,9 @@ export default defineCachedEventHandler(
version: rawVersion,
})
- const queryParams = v.safeParse(QUERY_SCHEMA, query)
+ const queryParams = v.safeParse(BadgeQuerySchema, query)
const userColor = queryParams.success ? queryParams.output.color : undefined
- const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
+ const userLabelColor = queryParams.success ? queryParams.output.labelColor : undefined
const showName = queryParams.success && queryParams.output.name === 'true'
const userLabel = queryParams.success ? queryParams.output.label : undefined
const userValue = queryParams.success ? queryParams.output.value : undefined
@@ -545,29 +258,22 @@ export default defineCachedEventHandler(
const pkgData = await fetchNpmPackage(packageName)
const strategyResult = await strategy(pkgData, requestedVersion)
- const finalLabel = userLabel ? userLabel : showName ? packageName : strategyResult.label
- const finalValue = userValue ? userValue : strategyResult.value
-
- const rawColor = userColor ?? strategyResult.color
- const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
-
- const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
- const rawLabelColor = labelColor ?? defaultLabelColor
- const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
-
- const labelTextColor = getContrastTextColor(finalLabelColor)
- const valueTextColor = getContrastTextColor(finalColor)
-
- const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
- const svg = renderFn({
- finalColor,
- finalLabel,
- finalLabelColor,
- finalValue,
- labelTextColor,
- valueTextColor,
+ const appearance = resolveBadgeAppearance({
+ strategyLabel: strategyResult.label,
+ strategyValue: strategyResult.value,
+ strategyColor: strategyResult.color,
+ badgeStyle,
+ packageName,
+ userLabel,
+ userValue,
+ userColor,
+ userLabelColor,
+ showName,
})
+ const renderFn = BADGE_RENDERERS[badgeStyle]
+ const svg = renderFn(appearance)
+
setHeader(event, 'Content-Type', 'image/svg+xml')
setHeader(
event,
diff --git a/server/api/registry/badge/compare/[type]/[...pkg].get.ts b/server/api/registry/badge/compare/[type]/[...pkg].get.ts
new file mode 100644
index 0000000000..b2c5b55b99
--- /dev/null
+++ b/server/api/registry/badge/compare/[type]/[...pkg].get.ts
@@ -0,0 +1,349 @@
+import * as v from 'valibot'
+import { hash } from 'ohash'
+import { createError, getRouterParam, getQuery, setHeader } from 'h3'
+import { PackageRouteParamsSchema } from '#shared/schemas/package'
+import { CACHE_MAX_AGE_ONE_YEAR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
+import { fetchNpmPackage } from '#server/utils/npm'
+import { assertValidPackageName } from '#shared/utils/npm'
+import { handleApiError } from '#server/utils/error-handler'
+import {
+ BADGE_COLORS,
+ BADGE_RENDERERS,
+ BadgeQuerySchema,
+ BadgeStyleSchema,
+ formatBadgeBytes,
+ resolveBadgeAppearance,
+} from '#server/utils/badges/render'
+
+const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
+const COMPARE_ARROW = '→'
+const SIZE_DELTA_THRESHOLD = 0.05
+
+interface CompareStrategyContext {
+ fromPkgData: globalThis.Packument
+ fromVersion: string
+ toPkgData: globalThis.Packument
+ toVersion: string
+}
+
+interface CompareStrategyResult {
+ label: string
+ fromValue: string
+ toValue: string
+ color: string
+}
+
+function getVersionData(
+ pkgData: globalThis.Packument,
+ version: string,
+): PackumentVersion | undefined {
+ return pkgData.versions?.[version]
+}
+
+function assertVersionExists(pkgData: globalThis.Packument, version: string, role: string) {
+ if (!getVersionData(pkgData, version)) {
+ throw createError({
+ statusCode: 404,
+ message: `Version "${version}" of "${pkgData.name}" not found (${role}).`,
+ })
+ }
+}
+
+function deltaColor(from: number, to: number, threshold = SIZE_DELTA_THRESHOLD): string {
+ if (from === to) return BADGE_COLORS.slate
+ if (from === 0) {
+ return to > 0 ? BADGE_COLORS.red : BADGE_COLORS.slate
+ }
+ const ratio = (to - from) / from
+ if (Math.abs(ratio) < threshold) return BADGE_COLORS.slate
+ return ratio > 0 ? BADGE_COLORS.red : BADGE_COLORS.green
+}
+
+async function fetchInstallSize(packageName: string, version: string): Promise {
+ try {
+ const response = await fetch(`${BUNDLEPHOBIA_API}?package=${packageName}@${version}`)
+ if (!response.ok) return null
+ const data = await response.json()
+ return typeof data.size === 'number' ? data.size : null
+ } catch {
+ return null
+ }
+}
+
+async function resolveSize(pkgData: globalThis.Packument, version: string): Promise {
+ const installSize = await fetchInstallSize(pkgData.name, version)
+ if (installSize !== null) return installSize
+ return getVersionData(pkgData, version)?.dist?.unpackedSize ?? 0
+}
+
+const compareBadgeStrategies = {
+ version: async ({
+ fromVersion,
+ toVersion,
+ }: CompareStrategyContext): Promise => {
+ return {
+ label: 'version',
+ fromValue: `v${fromVersion}`,
+ toValue: `v${toVersion}`,
+ color: BADGE_COLORS.blue,
+ }
+ },
+
+ size: async ({
+ fromPkgData,
+ fromVersion,
+ toPkgData,
+ toVersion,
+ }: CompareStrategyContext): Promise => {
+ const [fromBytes, toBytes] = await Promise.all([
+ resolveSize(fromPkgData, fromVersion),
+ resolveSize(toPkgData, toVersion),
+ ])
+ return {
+ label: 'install size',
+ fromValue: formatBadgeBytes(fromBytes),
+ toValue: formatBadgeBytes(toBytes),
+ color: deltaColor(fromBytes, toBytes),
+ }
+ },
+
+ dependencies: async ({
+ fromPkgData,
+ fromVersion,
+ toPkgData,
+ toVersion,
+ }: CompareStrategyContext): Promise => {
+ const fromCount = Object.keys(
+ getVersionData(fromPkgData, fromVersion)?.dependencies ?? {},
+ ).length
+ const toCount = Object.keys(getVersionData(toPkgData, toVersion)?.dependencies ?? {}).length
+ return {
+ label: 'dependencies',
+ fromValue: String(fromCount),
+ toValue: String(toCount),
+ color: deltaColor(fromCount, toCount),
+ }
+ },
+
+ license: async ({
+ fromPkgData,
+ fromVersion,
+ toPkgData,
+ toVersion,
+ }: CompareStrategyContext): Promise => {
+ const fromLicense = getVersionData(fromPkgData, fromVersion)?.license ?? 'unknown'
+ const toLicense = getVersionData(toPkgData, toVersion)?.license ?? 'unknown'
+ return {
+ label: 'license',
+ fromValue: fromLicense,
+ toValue: toLicense,
+ color: fromLicense === toLicense ? BADGE_COLORS.green : BADGE_COLORS.yellow,
+ }
+ },
+
+ engines: async ({
+ fromPkgData,
+ fromVersion,
+ toPkgData,
+ toVersion,
+ }: CompareStrategyContext): Promise => {
+ const fromEngine = getVersionData(fromPkgData, fromVersion)?.engines?.node ?? '*'
+ const toEngine = getVersionData(toPkgData, toVersion)?.engines?.node ?? '*'
+ return {
+ label: 'node',
+ fromValue: fromEngine,
+ toValue: toEngine,
+ color: fromEngine === toEngine ? BADGE_COLORS.slate : BADGE_COLORS.yellow,
+ }
+ },
+}
+
+const CompareBadgeTypeSchema = v.picklist(
+ Object.keys(compareBadgeStrategies) as [string, ...string[]],
+)
+
+interface CompareTarget {
+ packageName: string
+ version: string
+}
+
+interface ParsedCompareUrl {
+ from: CompareTarget
+ to: CompareTarget
+ /** True when both sides resolve to the same package name. Lets us de-dupe the
+ * npm registry fetch and pick a more compact value format. */
+ isSamePackage: boolean
+}
+
+/**
+ * Parse the path segments after `/api/registry/badge/compare/{type}/`.
+ *
+ * Supports two forms:
+ * - **Same-package shorthand**: `{pkg}/v/{from}...{to}` —
+ * e.g. `nuxt/v/2.18.1...4.3.1`
+ * - **Cross-package** (uses `vs` separator): `{pkgA}/v/{verA}/vs/{pkgB}/v/{verB}` —
+ * e.g. `nuxt/v/4.3.1/vs/next/v/15.0.0`
+ *
+ * Returns null on shapes that don't match either form so the handler can
+ * surface a helpful 400.
+ */
+function parseCompareUrlSegments(segments: string[]): ParsedCompareUrl | null {
+ const vsIndex = segments.indexOf('vs')
+
+ if (vsIndex !== -1) {
+ const left = segments.slice(0, vsIndex)
+ const right = segments.slice(vsIndex + 1)
+ if (left.length === 0 || right.length === 0) return null
+
+ const leftParsed = parsePackageParams(left)
+ const rightParsed = parsePackageParams(right)
+ if (!leftParsed.rawVersion || !rightParsed.rawVersion) return null
+
+ return {
+ from: { packageName: leftParsed.rawPackageName, version: leftParsed.rawVersion },
+ to: { packageName: rightParsed.rawPackageName, version: rightParsed.rawVersion },
+ isSamePackage: leftParsed.rawPackageName === rightParsed.rawPackageName,
+ }
+ }
+
+ // Same-package shorthand
+ const parsed = parsePackageParams(segments)
+ if (!parsed.rawVersion) return null
+ const range = parseVersionRange(parsed.rawVersion)
+ if (!range) return null
+ return {
+ from: { packageName: parsed.rawPackageName, version: range.from },
+ to: { packageName: parsed.rawPackageName, version: range.to },
+ isSamePackage: true,
+ }
+}
+
+function buildCompareValue(result: CompareStrategyResult): string {
+ return `${result.fromValue} ${COMPARE_ARROW} ${result.toValue}`
+}
+
+export default defineCachedEventHandler(
+ async event => {
+ const query = getQuery(event)
+ const typeParam = getRouterParam(event, 'type')
+ const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
+
+ if (pkgParamSegments.length === 0) {
+ throw createError({ statusCode: 404, message: 'Package name is required.' })
+ }
+
+ const parsedUrl = parseCompareUrlSegments(pkgParamSegments)
+ if (!parsedUrl) {
+ throw createError({
+ statusCode: 400,
+ message:
+ 'Invalid compare URL. Use `{pkg}/v/{from}...{to}` for same-package compare or `{pkgA}/v/{verA}/vs/{pkgB}/v/{verB}` for cross-package compare.',
+ })
+ }
+
+ try {
+ const fromParams = v.parse(PackageRouteParamsSchema, {
+ packageName: parsedUrl.from.packageName,
+ version: parsedUrl.from.version,
+ })
+ const toParams = v.parse(PackageRouteParamsSchema, {
+ packageName: parsedUrl.to.packageName,
+ version: parsedUrl.to.version,
+ })
+ // Both sides use the optional-version schema, but at this point the
+ // URL parser guarantees a version is present for each side.
+ const fromVersion = fromParams.version!
+ const toVersion = toParams.version!
+
+ assertValidPackageName(fromParams.packageName)
+ assertValidPackageName(toParams.packageName)
+
+ const queryParams = v.safeParse(BadgeQuerySchema, query)
+ const userColor = queryParams.success ? queryParams.output.color : undefined
+ const userLabelColor = queryParams.success ? queryParams.output.labelColor : undefined
+ const showName = queryParams.success && queryParams.output.name === 'true'
+ const userLabel = queryParams.success ? queryParams.output.label : undefined
+ const userValue = queryParams.success ? queryParams.output.value : undefined
+ const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
+ const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
+
+ const badgeTypeResult = v.safeParse(CompareBadgeTypeSchema, typeParam)
+ if (!badgeTypeResult.success) {
+ throw createError({
+ statusCode: 404,
+ message: `Compare badge type "${typeParam}" is not supported. Supported types: ${Object.keys(compareBadgeStrategies).join(', ')}.`,
+ })
+ }
+ const strategy =
+ compareBadgeStrategies[badgeTypeResult.output as keyof typeof compareBadgeStrategies]
+
+ const [fromPkgData, toPkgData] = parsedUrl.isSamePackage
+ ? await fetchNpmPackage(fromParams.packageName).then(d => [d, d] as const)
+ : await Promise.all([
+ fetchNpmPackage(fromParams.packageName),
+ fetchNpmPackage(toParams.packageName),
+ ])
+
+ assertVersionExists(fromPkgData, fromVersion, 'from')
+ assertVersionExists(toPkgData, toVersion, 'to')
+
+ const strategyResult = await strategy({
+ fromPkgData,
+ fromVersion,
+ toPkgData,
+ toVersion,
+ })
+
+ // The rendered value never includes package names (cross-package badges
+ // stay visually compact; package context lives in the URL/aria-label).
+ // For `name=true` we mirror the regular single-package badge behavior
+ // and put the package name in the label — for cross-package this means
+ // both names joined by the compare arrow, e.g. `nuxt → next`.
+ const appearancePackageName = parsedUrl.isSamePackage
+ ? fromPkgData.name
+ : `${fromPkgData.name} ${COMPARE_ARROW} ${toPkgData.name}`
+
+ const appearance = resolveBadgeAppearance({
+ strategyLabel: strategyResult.label,
+ strategyValue: buildCompareValue(strategyResult),
+ strategyColor: strategyResult.color,
+ badgeStyle,
+ packageName: appearancePackageName,
+ userLabel,
+ userValue,
+ userColor,
+ userLabelColor,
+ showName,
+ })
+
+ const renderFn = BADGE_RENDERERS[badgeStyle]
+ const svg = renderFn(appearance)
+
+ setHeader(event, 'Content-Type', 'image/svg+xml')
+ setHeader(
+ event,
+ 'Cache-Control',
+ `public, max-age=${CACHE_MAX_AGE_ONE_YEAR}, s-maxage=${CACHE_MAX_AGE_ONE_YEAR}`,
+ )
+
+ return svg
+ } catch (error: unknown) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_NPM_FETCH_FAILED,
+ })
+ }
+ },
+ {
+ // Comparing two pinned versions (same- or cross-package) is immutable,
+ // so cache permanently and let SWR keep things fresh.
+ maxAge: CACHE_MAX_AGE_ONE_YEAR,
+ swr: true,
+ getKey: event => {
+ const type = getRouterParam(event, 'type') ?? ''
+ const pkg = getRouterParam(event, 'pkg') ?? ''
+ const query = getQuery(event)
+ return `badge-compare:${type}:${pkg}:${hash(query)}`
+ },
+ },
+)
diff --git a/server/utils/badges/render.ts b/server/utils/badges/render.ts
new file mode 100644
index 0000000000..424df78ca7
--- /dev/null
+++ b/server/utils/badges/render.ts
@@ -0,0 +1,441 @@
+import * as v from 'valibot'
+import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
+
+export const BADGE_COLORS = {
+ blue: '#3b82f6',
+ green: '#22c55e',
+ purple: '#a855f7',
+ orange: '#f97316',
+ red: '#ef4444',
+ cyan: '#06b6d4',
+ slate: '#64748b',
+ yellow: '#eab308',
+ black: '#0a0a0a',
+ white: '#ffffff',
+} as const
+
+const BADGE_PADDING_X = 8
+const MIN_BADGE_TEXT_WIDTH = 40
+export 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'
+
+let cachedCanvasContext: SKRSContext2D | null | undefined
+
+const NARROW_CHARS = new Set([' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', ':', ';', '|'])
+const MEDIUM_CHARS = new Set([
+ '#',
+ '$',
+ '+',
+ '/',
+ '<',
+ '=',
+ '>',
+ '?',
+ '@',
+ '[',
+ '\\',
+ ']',
+ '^',
+ '_',
+ '`',
+ '{',
+ '}',
+ '~',
+])
+
+const FALLBACK_WIDTHS = {
+ default: {
+ narrow: 3,
+ medium: 5,
+ digit: 6,
+ uppercase: 7,
+ other: 6,
+ },
+ shieldsio: {
+ narrow: 3,
+ medium: 5,
+ digit: 6,
+ uppercase: 7,
+ other: 5.5,
+ },
+} as const
+
+function estimateTextWidth(text: string, fallbackFont: 'default' | 'shieldsio'): number {
+ // Heuristic coefficients tuned to keep fallback rendering close to canvas metrics.
+ const widths = FALLBACK_WIDTHS[fallbackFont]
+ let totalWidth = 0
+
+ for (const character of text) {
+ if (NARROW_CHARS.has(character)) {
+ totalWidth += widths.narrow
+ continue
+ }
+
+ if (MEDIUM_CHARS.has(character)) {
+ totalWidth += widths.medium
+ continue
+ }
+
+ if (/\d/.test(character)) {
+ totalWidth += widths.digit
+ continue
+ }
+
+ if (/[A-Z]/.test(character)) {
+ totalWidth += widths.uppercase
+ continue
+ }
+
+ totalWidth += widths.other
+ }
+
+ return Math.max(1, Math.round(totalWidth))
+}
+
+function getCanvasContext(): SKRSContext2D | null {
+ if (cachedCanvasContext !== undefined) {
+ return cachedCanvasContext
+ }
+
+ try {
+ cachedCanvasContext = createCanvas(1, 1).getContext('2d')
+ } catch {
+ cachedCanvasContext = null
+ }
+
+ return cachedCanvasContext
+}
+
+function measureTextWidth(text: string, font: string): number | null {
+ const context = getCanvasContext()
+
+ if (context) {
+ context.font = font
+
+ const measuredWidth = context.measureText(text).width
+
+ if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
+ return Math.ceil(measuredWidth)
+ }
+ }
+
+ return null
+}
+
+function measureDefaultTextWidth(text: string, fallbackExtraPadding = 0): number {
+ const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND)
+
+ if (measuredWidth !== null) {
+ return Math.max(MIN_BADGE_TEXT_WIDTH, measuredWidth + BADGE_PADDING_X * 2)
+ }
+
+ return Math.max(
+ MIN_BADGE_TEXT_WIDTH,
+ estimateTextWidth(text, 'default') + BADGE_PADDING_X * 2 + fallbackExtraPadding,
+ )
+}
+
+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 measureShieldsTextLength(text: string): number {
+ const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND)
+
+ if (measuredWidth !== null) {
+ return Math.max(1, measuredWidth)
+ }
+
+ return estimateTextWidth(text, 'shieldsio')
+}
+
+export function escapeBadgeXML(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+/**
+ * The character used by compare badges to separate `from` and `to` values.
+ * Exported so renderers can detect it for vertical-alignment adjustment.
+ */
+export const COMPARE_ARROW = '→'
+
+/**
+ * Wrap any compare-arrow occurrences in an already-XML-escaped value so the
+ * arrow renders aligned with the surrounding digits/letters instead of
+ * sitting at the math axis (visibly below the digits' visual middle in Geist
+ * and most sans-serif fallbacks at this font-size).
+ *
+ * Every segment goes into its own `` so the dy adjustments are
+ * relative-to-previous-tspan and don't get out of sync with bare text inside
+ * the parent `` (browsers handle that interleaving inconsistently and
+ * the result is visibly mis-aligned `from` vs. `to` digits). The arrow itself
+ * gets `dy="-1"` (one user unit up); the next segment then gets `dy="1"` to
+ * return to the parent baseline.
+ *
+ * Inputs without the arrow are returned untouched.
+ */
+export function wrapCompareArrow(escapedValue: string): string {
+ if (!escapedValue.includes(COMPARE_ARROW)) return escapedValue
+ const parts = escapedValue.split(COMPARE_ARROW)
+ let result = `${parts[0]}`
+ for (let i = 1; i < parts.length; i++) {
+ result += `${COMPARE_ARROW}${parts[i]}`
+ }
+ return result
+}
+
+function toLinear(c: number): number {
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
+}
+
+export function getBadgeContrastTextColor(bgHex: string): string {
+ let clean = bgHex.replace('#', '')
+ if (clean.length === 3)
+ clean = clean[0]! + clean[0]! + clean[1]! + clean[1]! + clean[2]! + clean[2]!
+ if (!/^[0-9a-f]{6}$/i.test(clean)) return '#ffffff'
+ const r = parseInt(clean.slice(0, 2), 16) / 255
+ const g = parseInt(clean.slice(2, 4), 16) / 255
+ const b = parseInt(clean.slice(4, 6), 16) / 255
+ const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
+ // threshold where contrast ratio with white equals contrast ratio with black
+ return luminance > 0.179 ? '#000000' : '#ffffff'
+}
+
+export interface BadgeRenderParams {
+ finalColor: string
+ finalLabel: string
+ finalLabelColor: string
+ finalValue: string
+ labelTextColor: string
+ valueTextColor: string
+}
+
+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 = wrapCompareArrow(escapeBadgeXML(finalLabel))
+ const escapedValue = wrapCompareArrow(escapeBadgeXML(finalValue))
+ // The aria-label ignores tspan adjustments and uses the raw arrow so screen
+ // readers receive the original "from → to" text.
+ const ariaLabel = `${escapeBadgeXML(finalLabel)}: ${escapeBadgeXML(finalValue)}`
+
+ return `
+
+ `.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: BadgeRenderParams): string {
+ const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
+ params
+ const hasLabel = finalLabel.trim().length > 0
+
+ const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
+ const rightTextLength = measureShieldsTextLength(finalValue)
+ const leftWidth = hasLabel ? leftTextLength + SHIELDS_LABEL_PADDING_X * 2 : 0
+ const rightWidth = rightTextLength + SHIELDS_LABEL_PADDING_X * 2
+ const totalWidth = leftWidth + rightWidth
+ const height = 20
+ const escapedLabelRaw = escapeBadgeXML(finalLabel)
+ const escapedValueRaw = escapeBadgeXML(finalValue)
+ const escapedLabel = wrapCompareArrow(escapedLabelRaw)
+ const escapedValue = wrapCompareArrow(escapedValueRaw)
+ const title = `${escapedLabelRaw}: ${escapedValueRaw}`
+
+ const leftCenter = Math.round((leftWidth / 2) * 10)
+ const rightCenter = Math.round((leftWidth + rightWidth / 2) * 10)
+ const leftTextLengthAttr = leftTextLength * 10
+ const rightTextLengthAttr = rightTextLength * 10
+
+ return `
+
+ `.trim()
+}
+
+export const BADGE_RENDERERS = {
+ default: renderDefaultBadgeSvg,
+ shieldsio: renderShieldsBadgeSvg,
+ compact: renderCompactBadgeSvg,
+} as const
+
+export const BadgeStyleSchema = v.picklist(['default', 'shieldsio', 'compact'])
+export type BadgeStyle = v.InferOutput
+
+export 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 const BadgeSafeStringSchema = v.pipe(v.string(), v.regex(/^[^<>"&]*$/, 'Invalid characters'))
+
+export const BadgeSafeColorSchema = v.pipe(
+ v.string(),
+ v.transform(value => (value.startsWith('#') ? value : `#${value}`)),
+ v.hexColor(),
+)
+
+export const BadgeQuerySchema = v.object({
+ name: v.optional(v.string()),
+ label: v.optional(BadgeSafeStringSchema),
+ value: v.optional(BadgeSafeStringSchema),
+ color: v.optional(BadgeSafeColorSchema),
+ labelColor: v.optional(BadgeSafeColorSchema),
+})
+
+export interface ResolveBadgeAppearanceInput {
+ strategyLabel: string
+ strategyValue: string
+ strategyColor: string
+ badgeStyle: BadgeStyle
+ packageName: string
+ userLabel?: string
+ userValue?: string
+ userColor?: string
+ userLabelColor?: string
+ showName?: boolean
+}
+
+export interface ResolvedBadgeAppearance {
+ finalLabel: string
+ finalValue: string
+ finalColor: string
+ finalLabelColor: string
+ labelTextColor: string
+ valueTextColor: string
+}
+
+/**
+ * Apply user overrides + style-specific label shortening + contrast text
+ * colors to a strategy's raw output. Used by all badge endpoints to keep
+ * customization parity (`label`, `value`, `color`, `labelColor`, `name`,
+ * `style=compact` shortening) consistent.
+ */
+export function resolveBadgeAppearance(
+ input: ResolveBadgeAppearanceInput,
+): ResolvedBadgeAppearance {
+ const strategyLabel =
+ input.badgeStyle === 'compact'
+ ? (COMPACT_LABEL_MAP[input.strategyLabel] ?? input.strategyLabel)
+ : input.strategyLabel
+
+ const finalLabel = input.userLabel
+ ? input.userLabel
+ : input.showName
+ ? input.packageName
+ : strategyLabel
+ const finalValue = input.userValue ? input.userValue : input.strategyValue
+
+ const rawColor = input.userColor ?? input.strategyColor
+ const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
+
+ const defaultLabelColor = input.badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
+ const rawLabelColor = input.userLabelColor ?? defaultLabelColor
+ const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+
+ return {
+ finalLabel,
+ finalValue,
+ finalColor,
+ finalLabelColor,
+ labelTextColor: getBadgeContrastTextColor(finalLabelColor),
+ valueTextColor: getBadgeContrastTextColor(finalColor),
+ }
+}
+
+export function formatBadgeBytes(bytes: number): string {
+ if (!+bytes) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
+ return `${value} ${sizes[i]}`
+}
+
+export function formatBadgeNumber(num: number): string {
+ return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(
+ num,
+ )
+}
+
+export function formatBadgeDate(dateString: string): string {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
diff --git a/test/e2e/badge-compare.spec.ts b/test/e2e/badge-compare.spec.ts
new file mode 100644
index 0000000000..763e6e4029
--- /dev/null
+++ b/test/e2e/badge-compare.spec.ts
@@ -0,0 +1,504 @@
+import { expect, test } from './test-utils'
+
+function toLocalUrl(baseURL: string | undefined, path: string): string {
+ if (!baseURL) return path
+ return baseURL.endsWith('/') ? `${baseURL}${path.slice(1)}` : `${baseURL}${path}`
+}
+
+async function fetchBadge(page: { request: { get: (url: string) => Promise } }, url: string) {
+ const response = await page.request.get(url)
+ const body = await response.text()
+ return { response, body }
+}
+
+function getSvgWidth(body: string): number {
+ const match = body.match(/