diff --git a/app/components/Package/ChartModal.vue b/app/components/Package/ChartModal.vue index 682200b58..08b4eefea 100644 --- a/app/components/Package/ChartModal.vue +++ b/app/components/Package/ChartModal.vue @@ -1,7 +1,11 @@ + + diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 8b522ee73..0bf59e4bc 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -271,6 +271,7 @@ const config = computed(() => { diff --git a/app/components/Settings/Toggle.client.vue b/app/components/Settings/Toggle.client.vue index d1619eaad..8d56de72c 100644 --- a/app/components/Settings/Toggle.client.vue +++ b/app/components/Settings/Toggle.client.vue @@ -1,9 +1,23 @@ diff --git a/app/composables/useChartWatermark.ts b/app/composables/useChartWatermark.ts new file mode 100644 index 000000000..36c267ce1 --- /dev/null +++ b/app/composables/useChartWatermark.ts @@ -0,0 +1,90 @@ +/** + * Shared utilities for chart watermarks and legends in SVG/PNG exports + */ + +interface WatermarkColors { + fg: string + bg: string + fgSubtle: string +} + +/** + * Build and return legend as SVG for export + * Legend items are displayed in a column, on the top left of the chart. + */ +export function drawSvgPrintLegend(svg: Record, colors: WatermarkColors) { + const data = Array.isArray(svg?.data) ? svg.data : [] + if (!data.length) return '' + + const seriesNames: string[] = [] + + data.forEach((serie, index) => { + seriesNames.push(` + + + ${serie.name} + + `) + }) + + return seriesNames.join('') +} + +/** + * Build and return npmx svg logo and tagline, to be injected during PNG & SVG exports + */ +export function drawNpmxLogoAndTaglineWatermark( + svg: Record, + colors: WatermarkColors, + translateFn: (key: string) => string, + positioning: 'bottom' | 'belowDrawingArea' = 'bottom', +) { + if (!svg?.drawingArea) return '' + const npmxLogoWidthToHeight = 2.64 + const npmxLogoWidth = 100 + const npmxLogoHeight = npmxLogoWidth / npmxLogoWidthToHeight + + // Position watermark based on the positioning strategy + const watermarkY = + positioning === 'belowDrawingArea' + ? svg.drawingArea.top + svg.drawingArea.height + 48 + : svg.height - npmxLogoHeight + + const taglineY = + positioning === 'belowDrawingArea' ? watermarkY - 6 : svg.height - npmxLogoHeight - 6 + + // Center the watermark horizontally relative to the full SVG width + const watermarkX = svg.width / 2 - npmxLogoWidth / 2 + + return ` + + + + + ${translateFn('tagline')} + + ` +} diff --git a/app/composables/useVersionDistribution.ts b/app/composables/useVersionDistribution.ts new file mode 100644 index 000000000..62f29d090 --- /dev/null +++ b/app/composables/useVersionDistribution.ts @@ -0,0 +1,175 @@ +import type { MaybeRefOrGetter } from 'vue' +import { toValue } from 'vue' +import type { + VersionDistributionResponse, + VersionGroupDownloads, + VersionGroupingMode, +} from '#shared/types/version-downloads' + +interface ChartDataItem { + name: string + downloads: number +} + +/** + * Composable for managing version download distribution data and state. + * + * Fetches version download statistics from the API, manages grouping/filtering state, + * and formats data for chart visualization. + * + * @param packageName - The package name to fetch version downloads for + * @returns Reactive state and computed chart data + */ +export function useVersionDistribution(packageName: MaybeRefOrGetter) { + const groupingMode = ref('major') + const showOldVersions = ref(false) + const showLowUsageVersions = ref(false) + const pending = ref(false) + const error = ref(null) + const data = ref(null) + + /** + * Fetches version download distribution from the API + */ + async function fetchDistribution() { + const pkgName = toValue(packageName) + if (!pkgName) { + data.value = null + return + } + + pending.value = true + error.value = null + + try { + const mode = groupingMode.value + const response = await $fetch( + `/api/registry/downloads/${encodeURIComponent(pkgName)}/versions`, + { + query: { + mode, + filterOldVersions: showOldVersions.value ? 'false' : 'true', + filterThreshold: showLowUsageVersions.value ? '0' : '1', + }, + cache: 'default', // Don't force-cache since query params change frequently + }, + ) + + data.value = response + } catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to fetch version distribution') + data.value = null + } finally { + pending.value = false + } + } + + /** + * Applies filtering to version groups based on current filter settings + * Sorts groups from oldest to newest version + */ + const filteredGroups = computed(() => { + if (!data.value) return [] + + let groups = data.value.groups + + // Filter using server-provided recent versions list + if (!showOldVersions.value && data.value.recentVersions) { + const recentVersionsSet = new Set(data.value.recentVersions) + + groups = groups.filter(group => { + return group.versions.some(v => { + // Check exact version match + if (recentVersionsSet.has(v.version)) return true + + // Also check base version (strip prerelease suffix) + if (v.version.includes('-')) { + const baseVersion = v.version.split('-')[0] + if (baseVersion && recentVersionsSet.has(baseVersion)) return true + } + + return false + }) + }) + } + + // Sort groups from oldest to newest by parsing version numbers + return groups.slice().sort((a, b) => { + // Extract version numbers from groupKey (e.g., "1.x" or "1.2.x") + const aParts = a.groupKey.replace(/\.x$/, '').split('.').map(Number) + const bParts = b.groupKey.replace(/\.x$/, '').split('.').map(Number) + + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aPart = aParts[i] ?? 0 + const bPart = bParts[i] ?? 0 + if (aPart !== bPart) { + return aPart - bPart + } + } + return 0 + }) + }) + + const chartDataset = computed(() => { + const groups = filteredGroups.value + if (!groups.length) return [] + + return groups.map(group => ({ + name: group.label, + downloads: group.downloads, + })) + }) + + const totalDownloads = computed(() => { + const groups = filteredGroups.value + if (!groups || !groups.length) return 0 + return groups.reduce((sum, group) => sum + group.downloads, 0) + }) + + const hasData = computed(() => { + return data.value !== null && filteredGroups.value.length > 0 + }) + + // Refetch when filter changes - no immediate since we already have data + watch(showOldVersions, () => { + fetchDistribution() + }) + + watch(showLowUsageVersions, () => { + fetchDistribution() + }) + + // Refetch when grouping mode changes - immediate to load initial data + watch( + groupingMode, + () => { + fetchDistribution() + }, + { immediate: true }, + ) + + // Refetch when package name changes - not immediate since parent component controls initialization + watch( + () => toValue(packageName), + () => { + fetchDistribution() + }, + { immediate: false }, + ) + + return { + // State + groupingMode, + showOldVersions, + showLowUsageVersions, + pending, + error, + // Computed + filteredGroups, + chartDataset, + totalDownloads, + hasData, + // Methods + fetchDistribution, + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b83fcc59b..11357c1ed 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -287,7 +287,16 @@ "more_tagged": "{count} more tagged", "all_covered": "All versions are covered by tags above", "deprecated_title": "{version} (deprecated)", - "view_all": "View {count} version | View all {count} versions" + "view_all": "View {count} version | View all {count} versions", + "distribution_title": "Semver Group", + "distribution_modal_title": "Versions", + "grouping_major": "Major", + "grouping_minor": "Minor", + "show_old_versions": "Show old versions", + "show_old_versions_tooltip": "Show versions older than 1 year", + "show_low_usage": "Show low usage versions", + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", + "date_range_tooltip": "Last week of version distributions only" }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/i18n/schema.json b/i18n/schema.json index 47977f0b2..54a137028 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -867,6 +867,33 @@ }, "view_all": { "type": "string" + }, + "distribution_title": { + "type": "string" + }, + "distribution_modal_title": { + "type": "string" + }, + "grouping_major": { + "type": "string" + }, + "grouping_minor": { + "type": "string" + }, + "show_old_versions": { + "type": "string" + }, + "show_old_versions_tooltip": { + "type": "string" + }, + "show_low_usage": { + "type": "string" + }, + "show_low_usage_tooltip": { + "type": "string" + }, + "date_range_tooltip": { + "type": "string" } }, "additionalProperties": false diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 56f795555..c8f1ed82d 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -286,7 +286,16 @@ "more_tagged": "{count} more tagged", "all_covered": "All versions are covered by tags above", "deprecated_title": "{version} (deprecated)", - "view_all": "View {count} version | View all {count} versions" + "view_all": "View {count} version | View all {count} versions", + "distribution_title": "Semver Group", + "distribution_modal_title": "Versions", + "grouping_major": "Major", + "grouping_minor": "Minor", + "show_old_versions": "Show old versions", + "show_old_versions_tooltip": "Show versions older than 1 year", + "show_low_usage": "Show low usage versions", + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", + "date_range_tooltip": "Last week of version distributions only" }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index d8666cdf5..3665bb6e4 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -286,7 +286,16 @@ "more_tagged": "{count} more tagged", "all_covered": "All versions are covered by tags above", "deprecated_title": "{version} (deprecated)", - "view_all": "View {count} version | View all {count} versions" + "view_all": "View {count} version | View all {count} versions", + "distribution_title": "Semver Group", + "distribution_modal_title": "Versions", + "grouping_major": "Major", + "grouping_minor": "Minor", + "show_old_versions": "Show old versions", + "show_old_versions_tooltip": "Show versions older than 1 year", + "show_low_usage": "Show low usage versions", + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", + "date_range_tooltip": "Last week of version distributions only" }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/nuxt.config.ts b/nuxt.config.ts index 3311ee081..5965c351e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -101,6 +101,13 @@ export default defineNuxtConfig({ allowQuery: ['color', 'labelColor', 'label', 'name'], }, }, + '/api/registry/downloads/**': { + isr: { + expiration: 60 * 60 /* one hour */, + passQuery: true, + allowQuery: ['mode', 'filterOldVersions', 'filterThreshold'], + }, + }, '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, diff --git a/server/api/registry/downloads/[...slug].get.ts b/server/api/registry/downloads/[...slug].get.ts new file mode 100644 index 000000000..8388d6c25 --- /dev/null +++ b/server/api/registry/downloads/[...slug].get.ts @@ -0,0 +1,152 @@ +import { getQuery } from 'h3' +import * as v from 'valibot' +import { hash } from 'ohash' +import type { VersionDistributionResponse } from '#shared/types' +import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' +import { groupVersionDownloads } from '#server/utils/version-downloads' + +/** + * Raw response from npm downloads API + * GET https://api.npmjs.org/versions/{package}/last-week + */ +interface NpmVersionDownloadsResponse { + package: string + downloads: Record +} + +/** + * Query parameter validation schema + */ +const QuerySchema = v.object({ + mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'), + filterThreshold: v.optional( + v.pipe( + v.string(), + v.toNumber(), // Fails validation on invalid conversion (e.g., "abc") instead of producing NaN + v.minValue(0), // Ensure non-negative values + ), + ), + filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'), +}) + +/** + * GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions + * + * Fetch per-version download statistics and group by major or minor version. + * Data is cached for 1 hour with stale-while-revalidate. + * + * Query parameters: + * - mode: 'major' | 'minor' (default: 'major') + * - filterThreshold: minimum percentage to include (default: 1) + * - filterOldVersions: 'true' to include only versions published in last year (default: 'false') + */ +export default defineCachedEventHandler( + async event => { + // Supports: /downloads/lodash/versions, /downloads/@scope/name/versions + const slugParam = getRouterParam(event, 'slug') + const pkgParamSegments = slugParam?.split('/') ?? [] + + const lastSegment = pkgParamSegments.at(-1) + if (!lastSegment || lastSegment !== 'versions') { + throw createError({ + statusCode: 404, + message: 'Invalid endpoint. Expected /versions', + }) + } + + const segments = pkgParamSegments.slice(0, -1) + + const { rawPackageName } = parsePackageParams(segments) + + if (!rawPackageName) { + throw createError({ + statusCode: 404, + message: 'Package name is required', + }) + } + + try { + const query = getQuery(event) + const parsed = v.parse(QuerySchema, query) + const mode = parsed.mode + const filterThreshold = parsed.filterThreshold ?? 1 + const filterOldVersionsBool = parsed.filterOldVersions === 'true' + + const url = `https://api.npmjs.org/versions/${rawPackageName}/last-week` + const npmResponse = await fetch(url) + + if (!npmResponse.ok) { + if (npmResponse.status === 404) { + throw createError({ + statusCode: 404, + message: 'Package not found', + }) + } + throw createError({ + statusCode: 502, + message: 'Failed to fetch version download data from npm API', + }) + } + + const data: NpmVersionDownloadsResponse = await npmResponse.json() + + let groups = groupVersionDownloads(data.downloads, mode) + + if (filterThreshold > 0) { + groups = groups.filter(group => group.percentage >= filterThreshold) + } + + const totalDownloads = Object.values(data.downloads).reduce((sum, count) => sum + count, 0) + + const apiResponse: VersionDistributionResponse = { + package: rawPackageName, + mode, + totalDownloads, + groups, + timestamp: new Date().toISOString(), + } + + if (filterOldVersionsBool) { + try { + const oneYearAgo = new Date() + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + const afterDate = oneYearAgo.toISOString() + + // Decode package name in case it's URL-encoded (e.g., %40prisma%2Fclient -> @prisma/client) + const decodedPackageName = decodeURIComponent(rawPackageName) + + // Fetch directly from npm-fast-meta HTTP API + const fastMetaUrl = `https://npm.antfu.dev/versions/${encodeURIComponent(decodedPackageName)}?after=${encodeURIComponent(afterDate)}` + const fastMetaResponse = await fetch(fastMetaUrl) + + if (!fastMetaResponse.ok) { + throw new Error(`npm-fast-meta returned ${fastMetaResponse.status}`) + } + + const versionData = (await fastMetaResponse.json()) as { versions: string[] } + apiResponse.recentVersions = versionData.versions + } catch { + // Graceful degradation - don't fail entire request if npm-fast-meta fails + } + } + + return apiResponse + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to fetch version download distribution', + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR, + swr: true, + getKey: event => { + const slug = getRouterParam(event, 'slug') ?? '' + const query = getQuery(event) + // Use ohash to create deterministic cache key from query params + // This ensures different param combinations = different cache entries + return `version-downloads:v5:${slug}:${hash(query)}` + }, + }, +) diff --git a/server/utils/version-downloads.ts b/server/utils/version-downloads.ts new file mode 100644 index 000000000..2604a89ec --- /dev/null +++ b/server/utils/version-downloads.ts @@ -0,0 +1,169 @@ +import semver from 'semver' +import type { + VersionDownloadPoint, + VersionGroupDownloads, + VersionGroupingMode, +} from '#shared/types' + +/** + * Intermediate data structure for version processing + */ +interface ProcessedVersion { + version: string + downloads: number + major: number + minor: number + parsed: semver.SemVer +} + +/** + * Filter out versions below a usage threshold + * @param versions Array of version download points + * @param thresholdPercent Minimum percentage to include (default: 0.1%) + * @returns Filtered array of versions + */ +export function filterLowUsageVersions( + versions: VersionDownloadPoint[], + thresholdPercent: number = 0.1, +): VersionDownloadPoint[] { + return versions.filter(v => v.percentage >= thresholdPercent) +} + +/** + * Parse and validate version strings, calculating total downloads + * @param rawDownloads Raw download data from npm API + * @returns Array of processed versions with parsed semver data + */ +function parseVersions(rawDownloads: Record): ProcessedVersion[] { + const processed: ProcessedVersion[] = [] + + for (const [version, downloads] of Object.entries(rawDownloads)) { + const parsed = semver.parse(version) + if (!parsed) continue + + processed.push({ + version, + downloads, + major: parsed.major, + minor: parsed.minor, + parsed, + }) + } + + processed.sort((a, b) => semver.rcompare(a.version, b.version)) + + return processed +} + +/** + * Calculate percentage for each version + * @param versions Processed versions + * @param totalDownloads Total download count + * @returns Array of version download points with percentages + */ +function addPercentages( + versions: ProcessedVersion[], + totalDownloads: number, +): VersionDownloadPoint[] { + return versions.map(v => ({ + version: v.version, + downloads: v.downloads, + percentage: totalDownloads > 0 ? (v.downloads / totalDownloads) * 100 : 0, + })) +} + +/** + * Group versions by major version (e.g., 1.x, 2.x) + * @param rawDownloads Raw download data from npm API + * @returns Array of version groups sorted by downloads descending + */ +export function groupByMajor(rawDownloads: Record): VersionGroupDownloads[] { + const processed = parseVersions(rawDownloads) + const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0) + + const groups = new Map() + for (const version of processed) { + const existing = groups.get(version.major) || [] + existing.push(version) + groups.set(version.major, existing) + } + + const result: VersionGroupDownloads[] = [] + for (const [major, versions] of groups.entries()) { + const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0) + const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0 + + result.push({ + groupKey: `${major}.x`, + label: `v${major}.x`, + downloads: groupDownloads, + percentage, + versions: addPercentages(versions, totalDownloads), + }) + } + + result.sort((a, b) => b.downloads - a.downloads) + + return result +} + +/** + * Group versions by major.minor (e.g., 1.2.x, 1.3.x) + * Special handling for 0.x versions - treat them as separate majors + * @param rawDownloads Raw download data from npm API + * @returns Array of version groups sorted by downloads descending + */ +export function groupByMinor(rawDownloads: Record): VersionGroupDownloads[] { + const processed = parseVersions(rawDownloads) + const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0) + + // Group by major.minor + const groups = new Map() + for (const version of processed) { + // For 0.x versions, treat each minor as significant (0.9.x, 0.10.x are different) + // For 1.x+, group by major.minor normally + const groupKey = `${version.major}.${version.minor}` + const existing = groups.get(groupKey) || [] + existing.push(version) + groups.set(groupKey, existing) + } + + // Convert to VersionGroupDownloads + const result: VersionGroupDownloads[] = [] + for (const [groupKey, versions] of groups.entries()) { + const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0) + const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0 + + result.push({ + groupKey: `${groupKey}.x`, + label: `v${groupKey}.x`, + downloads: groupDownloads, + percentage, + versions: addPercentages(versions, totalDownloads), + }) + } + + result.sort((a, b) => b.downloads - a.downloads) + + return result +} + +/** + * Group versions by the specified mode + * @param rawDownloads Raw download data from npm API + * @param mode Grouping mode ('major' or 'minor') + * @returns Array of version groups sorted by downloads descending + */ +export function groupVersionDownloads( + rawDownloads: Record, + mode: VersionGroupingMode, +): VersionGroupDownloads[] { + switch (mode) { + case 'major': + return groupByMajor(rawDownloads) + case 'minor': + return groupByMinor(rawDownloads) + default: + throw new Error(`Invalid grouping mode: ${mode}`) + } +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 96f55a32b..88e28afe0 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -8,3 +8,4 @@ export * from './deno-doc' export * from './i18n-status' export * from './comparison' export * from './skills' +export * from './version-downloads' diff --git a/shared/types/version-downloads.ts b/shared/types/version-downloads.ts new file mode 100644 index 000000000..203aed3d0 --- /dev/null +++ b/shared/types/version-downloads.ts @@ -0,0 +1,60 @@ +/** + * Version Downloads Distribution Types + * Types for version download statistics and grouping. + * + * These types support fetching per-version download counts from npm API + * and grouping them by major/minor versions for distribution analysis. + */ + +/** + * Download data for a single package version + */ +export interface VersionDownloadPoint { + /** Semantic version string (e.g., "1.2.3") */ + version: string + /** Download count for this version */ + downloads: number + /** Percentage of total downloads (0-100) */ + percentage: number +} + +/** + * Aggregated download data for a version group (major or minor) + */ +export interface VersionGroupDownloads { + /** Group identifier (e.g., "1.x" for major, "1.2.x" for minor) */ + groupKey: string + /** Human-readable label (e.g., "v1.x", "v1.2.x") */ + label: string + /** Total downloads for all versions in this group */ + downloads: number + /** Percentage of total downloads (0-100) */ + percentage: number + /** Individual versions in this group */ + versions: VersionDownloadPoint[] +} + +/** + * Mode for grouping versions + * - 'major': Group by major version (1.x, 2.x) + * - 'minor': Group by minor version (1.2.x, 1.3.x) + */ +export type VersionGroupingMode = 'major' | 'minor' + +/** + * API response for version download distribution + */ +export interface VersionDistributionResponse { + /** Package name */ + package: string + /** Grouping mode used */ + mode: VersionGroupingMode + /** Total downloads across all versions */ + totalDownloads: number + /** Grouped version data */ + groups: VersionGroupDownloads[] + /** ISO 8601 timestamp when data was fetched */ + timestamp: string + /** List of version strings published within the last year (only present when filterOldVersions=true) */ + recentVersions?: string[] +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 2a9f1cbd6..61133f5d7 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -594,8 +594,8 @@ describe('component accessibility audits', () => { describe('PackageChartModal', () => { it('should have no accessibility violations when closed', async () => { const component = await mountSuspended(PackageChartModal, { - props: { open: false }, - slots: { title: 'Downloads', default: '
Chart content
' }, + props: { open: false, title: 'Downloads' }, + slots: { default: '
Chart content
' }, }) const results = await runAxe(component) expect(results.violations).toEqual([]) diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 4795b936b..0f18a000e 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -40,6 +40,8 @@ const SKIPPED_COMPONENTS: Record = { 'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data', 'Package/WeeklyDownloadStats.vue': 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment', + 'Package/VersionDistribution.vue': + 'Uses vue-data-ui VueUiXy - has DOM measurement issues in test environment', 'UserCombobox.vue': 'Unused component - intended for future admin features', 'SkeletonBlock.vue': 'Already covered indirectly via other component tests', 'SkeletonInline.vue': 'Already covered indirectly via other component tests',