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 [![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/react/v/18.0.0)](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 + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/version/nuxt/v/3.0.0...3.21.0)](https://npmx.dev/package/nuxt) + +# Same-package install size delta (directional color) + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/size/nuxt/v/3.0.0...3.21.0)](https://npmx.dev/package/nuxt) + +# Dependency count delta on a scoped package + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/dependencies/@nuxt/kit/v/3.20.0...3.21.0)](https://npmx.dev/package/@nuxt/kit) + +# Cross-package install size comparison + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/size/nuxt/v/4.3.1/vs/next/v/15.5.11)](https://npmx.dev/package/nuxt) + +# Cross-package dependencies comparison + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/dependencies/nuxt/v/4.3.1/vs/next/v/15.5.11)](https://npmx.dev/package/nuxt) + +# Compact compare badge (e.g. for tight READMEs) + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/compare/dependencies/nuxt/v/3.0.0...3.21.0?style=compact)](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 ` - - - - - - - - - - ${escapedLabel} - ${escapedValue} - - - `.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 ` - - - - - - - - - - - - - - - - ${escapedLabel} - - ${escapedValue} - - - `.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 ` + + + + + + + + + + ${escapedLabel} + ${escapedValue} + + + `.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 ` + + + + + + + + + + + + + + + + ${escapedLabel} + + ${escapedValue} + + + `.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(/]*\swidth="(\d+)"/) + return match ? Number(match[1]) : 0 +} + +/** The aria-label preserves the canonical `{label}: {value}` text without the + * `` wrapping that the renderer applies around compare arrows for + * vertical alignment. Use it whenever the test cares about *what the badge + * reads as*, rather than the exact tspan markup. */ +function getAriaLabel(body: string): string { + return body.match(/aria-label="([^"]+)"/)?.[1] ?? '' +} + +const ARROW = '→' + +test.describe('compare badge API', () => { + test.describe('per-strategy rendering', () => { + test('version compare renders both versions with arrow', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1`) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toContain('image/svg+xml') + expect(body).toContain('>version<') + expect(getAriaLabel(body)).toContain(`v2.18.1 ${ARROW} v4.3.1`) + expect(body).toContain('fill="#3b82f6"') + }) + + test('size compare renders both formatted sizes with arrow', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/size/nuxt/v/2.18.1...4.3.1`) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(body).toContain('>install size<') + expect(getAriaLabel(body)).toContain(`14.22 KB ${ARROW} 52.7 KB`) + }) + + test('size compare colors red when size grew significantly', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/size/nuxt/v/2.18.1...4.3.1`) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#ef4444"') + }) + + test('size compare colors green when size shrunk significantly', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/size/nuxt/v/4.3.1...2.18.1`) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#22c55e"') + }) + + test('size compare colors slate when sizes are identical', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/size/nuxt/v/4.3.1...4.3.1`) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#64748b"') + }) + + test('dependencies compare renders both counts with arrow', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1`, + ) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(body).toContain('>dependencies<') + expect(getAriaLabel(body)).toContain(`15 ${ARROW} 57`) + }) + + test('dependencies compare colors red when dep count grew', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#ef4444"') + }) + + test('dependencies compare colors green when dep count shrunk', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/4.3.1...2.18.1`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#22c55e"') + }) + + test('license compare colors green when license unchanged', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/license/nuxt/v/2.18.1...4.3.1`) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(body).toContain('>license<') + expect(getAriaLabel(body)).toContain(`MIT ${ARROW} MIT`) + expect(body).toContain('fill="#22c55e"') + }) + + test('engines compare renders both ranges and yellow when changed', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/engines/nuxt/v/2.18.1...4.3.1`) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(body).toContain('>node<') + expect(getAriaLabel(body)).toContain(ARROW) + expect(body).toContain('fill="#eab308"') + }) + + test('engines compare colors slate when range unchanged', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/engines/nuxt/v/4.3.1...4.0.0-alpha.4`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#64748b"') + }) + }) + + test.describe('routing', () => { + test('scoped package compare renders successfully', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/@nuxt/kit/v/3.20.0...3.21.0`, + ) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(getAriaLabel(body)).toContain(`v3.20.0 ${ARROW} v3.21.0`) + }) + + test('missing version range returns 400', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/nuxt`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(400) + }) + + test('invalid version range format returns 400', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/nuxt/v/2.18.1`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(400) + }) + + test('non-existent from-version returns 404', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/0.0.99...4.3.1`, + ) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(404) + }) + + test('non-existent to-version returns 404', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/4.3.1...99.99.99`, + ) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(404) + }) + + test('unsupported badge type returns 404', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/downloads/nuxt/v/2.18.1...4.3.1`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(404) + }) + + test('missing package returns 404', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(404) + }) + + test('long-cache headers set on success', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1`) + const { response } = await fetchBadge(page, url) + + const cacheControl = response.headers()['cache-control'] + expect(cacheControl).toContain(`s-maxage=${60 * 60 * 24 * 365}`) + }) + }) + + test.describe('styles', () => { + test('default style uses Geist font', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?style=default`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"') + }) + + test('shieldsio style uses Verdana font', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?style=shieldsio`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"') + }) + + test('compact style shortens long built-in labels', async ({ page, baseURL }) => { + const cases: Array<[string, string, string]> = [ + ['size', 'install size', 'size'], + ['dependencies', 'dependencies', 'deps'], + ] + for (const [type, fullLabel, shortLabel] of cases) { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/${type}/nuxt/v/2.18.1...4.3.1?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('compact style produces a narrower badge than default for shortened labels', async ({ + page, + baseURL, + }) => { + const defaultUrl = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1?style=default`, + ) + const compactUrl = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1?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('compact style does not trim a user-supplied label', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1?style=compact&label=my-deps`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('>my-deps<') + expect(body).not.toContain('>deps<') + }) + + test('compact style uses package name when name=true', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1?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.describe('arrow alignment', () => { + test('arrow is wrapped in a dy-shifted tspan so it sits on the digit center', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1`, + ) + const { body } = await fetchBadge(page, url) + + // Without the wrap, the arrow sits below the digits' visual middle + // (it renders near the math axis at this font size). The renderer + // therefore wraps it in a `dy="-1"` tspan and the trailing segment in + // a `dy="1"` tspan so the digits stay on the parent baseline. + expect(body).toMatch(/]*\sdy="-1">→<\/tspan>/) + expect(body).toMatch(/]*\sdy="1">/) + }) + + test('aria-label keeps the raw arrow without tspan markup for screen readers', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1`, + ) + const { body } = await fetchBadge(page, url) + + const aria = getAriaLabel(body) + expect(aria).not.toContain('tspan') + expect(aria).toContain(`15 ${ARROW} 57`) + }) + }) + + test.describe('cross-package compare', () => { + test('renders raw from/to values without package names', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/4.3.1/vs/next/v/15.5.11`, + ) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + expect(body).toContain('>dependencies<') + expect(getAriaLabel(body)).toContain(`57 ${ARROW} 5`) + // Package names live in the URL and aria-label only — they should not + // be embedded in the rendered value. + expect(getAriaLabel(body)).not.toContain('nuxt 57') + expect(getAriaLabel(body)).not.toContain('next 5') + }) + + test('version cross-pkg shows both raw versions only', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/4.3.1/vs/next/v/15.5.11`, + ) + const { body } = await fetchBadge(page, url) + + expect(getAriaLabel(body)).toContain(`v4.3.1 ${ARROW} v15.5.11`) + expect(getAriaLabel(body)).not.toContain('nuxt v4.3.1') + }) + + test('directional color uses delta of cross-pkg counts', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/4.3.1/vs/next/v/15.5.11`, + ) + const { body } = await fetchBadge(page, url) + + // 57 → 5 is a clear shrink, so the value side should render green. + expect(body).toContain('fill="#22c55e"') + }) + + test('license compare across packages with same license stays green', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/license/nuxt/v/4.3.1/vs/next/v/15.5.11`, + ) + const { body } = await fetchBadge(page, url) + + expect(getAriaLabel(body)).toContain(`MIT ${ARROW} MIT`) + expect(body).toContain('fill="#22c55e"') + }) + + test('engines compare across packages goes yellow when ranges differ', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/engines/nuxt/v/4.3.1/vs/next/v/15.5.11`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('>node<') + expect(body).toContain('fill="#eab308"') + }) + + test('scoped → unscoped cross-pkg renders successfully', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/@nuxt/kit/v/3.21.0/vs/next/v/15.5.11`, + ) + const { response, body } = await fetchBadge(page, url) + + expect(response.status()).toBe(200) + const aria = getAriaLabel(body) + expect(aria).toContain(ARROW) + // The aria-label uses the strategy label (e.g. "dependencies"), not + // the package names — which are only in the URL. + expect(aria).not.toContain('@nuxt/kit') + expect(aria).not.toContain('next') + }) + + test('name=true on cross-pkg shows "{pkgA} → {pkgB}" as the label', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/4.3.1/vs/next/v/15.5.11?name=true`, + ) + const { body } = await fetchBadge(page, url) + + // The label carries both names like a regular `name=true` badge would + // carry the single package name; the value still has no names in it. + expect(getAriaLabel(body)).toMatch(/^nuxt → next:/) + expect(getAriaLabel(body)).toContain(`57 ${ARROW} 5`) + expect(getAriaLabel(body)).not.toContain('nuxt 57') + expect(body).not.toContain('>dependencies<') + }) + + test('cross-pkg with vs but missing version on one side returns 400', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl(baseURL, `/api/registry/badge/compare/version/nuxt/vs/next/v/15.5.11`) + const { response } = await fetchBadge(page, url) + + expect(response.status()).toBe(400) + }) + + test('cross-pkg with non-existent package returns 404 or 502', async ({ page, baseURL }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/4.3.1/vs/this-package-does-not-exist-zzzzz/v/1.0.0`, + ) + const { response } = await fetchBadge(page, url) + + // Non-existent npm packages bubble up as the npm fetch error path. + expect([404, 502]).toContain(response.status()) + }) + }) + + test.describe('customization', () => { + test('custom label parameter is applied', async ({ page, baseURL }) => { + const customLabel = 'compare-version' + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?label=${customLabel}`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(customLabel) + }) + + test('custom value parameter is applied', async ({ page, baseURL }) => { + const customValue = 'much-faster-now' + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?value=${encodeURIComponent(customValue)}`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(customValue) + }) + + test('custom color parameter overrides directional color', async ({ page, baseURL }) => { + const customColor = 'ff69b4' + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/dependencies/nuxt/v/2.18.1...4.3.1?color=${customColor}`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(`fill="#${customColor}"`) + }) + + test('custom labelColor parameter is applied', async ({ page, baseURL }) => { + const customColor = '00ff00' + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?labelColor=${customColor}`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain(`fill="#${customColor}"`) + }) + + test('name=true replaces the strategy label with the package name', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl( + baseURL, + `/api/registry/badge/compare/version/nuxt/v/2.18.1...4.3.1?name=true`, + ) + const { body } = await fetchBadge(page, url) + + expect(body).toContain('>nuxt<') + expect(body).not.toContain('>version<') + }) + }) +}) 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) diff --git a/test/fixtures/mock-routes.cjs b/test/fixtures/mock-routes.cjs index 8992850120..7a733d72f0 100644 --- a/test/fixtures/mock-routes.cjs +++ b/test/fixtures/mock-routes.cjs @@ -384,9 +384,24 @@ function matchBundlephobiaApi(urlString) { if (url.pathname === '/api/size') { const packageSpec = url.searchParams.get('package') if (packageSpec) { + // Split on the LAST '@' so scoped packages (e.g. "@scope/name@1.0.0") + // resolve to (name="@scope/name", version="1.0.0"). + const atIdx = packageSpec.lastIndexOf('@') + const isScopedWithoutVersion = atIdx <= 0 + const name = isScopedWithoutVersion ? packageSpec : packageSpec.slice(0, atIdx) + const version = isScopedWithoutVersion ? null : packageSpec.slice(atIdx + 1) + // Deterministic size derived from the version when one is provided so + // that compare badge tests can verify size deltas. Falls back to 12345 + // when no version is supplied to preserve previous fixture behavior. + let size = 12345 + if (version) { + let h = 0 + for (const ch of version) h = (h * 31 + ch.charCodeAt(0)) >>> 0 + size = 10000 + (h % 50000) + } return json({ - name: packageSpec.split('@')[0], - size: 12345, + name, + size, gzip: 4567, dependencyCount: 3, })