diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue
index ae779a39..a372fe75 100644
--- a/app/components/AppHeader.vue
+++ b/app/components/AppHeader.vue
@@ -77,6 +77,14 @@ onKeyStroke(',', e => {
:class="{ 'hidden sm:flex': showFullSearch }"
class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"
>
+
+
+ {{ $t('nav.compare') }}
+
+
+defineProps<{
+ /** Number of columns (2-4) */
+ columns: number
+ /** Column headers (package names or version numbers) */
+ headers: string[]
+}>()
+
+
+
+
+
+
+
diff --git a/app/components/compare/FacetSelector.vue b/app/components/compare/FacetSelector.vue
new file mode 100644
index 00000000..447b6821
--- /dev/null
+++ b/app/components/compare/FacetSelector.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+ {{ t(`compare.facets.categories.${category}`) }}
+
+
+ /
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/compare/MetricRow.vue b/app/components/compare/MetricRow.vue
new file mode 100644
index 00000000..06e9efc9
--- /dev/null
+++ b/app/components/compare/MetricRow.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ {{ value.display }}
+
+
+
+
+
+ {{ diffs[index]?.display }}
+
+
+
+
+
diff --git a/app/components/compare/PackageSelector.vue b/app/components/compare/PackageSelector.vue
new file mode 100644
index 00000000..c8dc21a1
--- /dev/null
+++ b/app/components/compare/PackageSelector.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+ {{ pkg }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('compare.selector.searching') }}
+
+
+
+
+
+
+
+
+ {{ t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }}
+ {{ t('compare.selector.add_hint') }}
+
+
+
diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts
new file mode 100644
index 00000000..5b7de0a0
--- /dev/null
+++ b/app/composables/useFacetSelection.ts
@@ -0,0 +1,124 @@
+import type { ComparisonFacet } from '#shared/types'
+import { ALL_FACETS, DEFAULT_FACETS, FACET_INFO } from '#shared/types/comparison'
+import { useRouteQuery } from '@vueuse/router'
+
+/**
+ * Composable for managing comparison facet selection with URL sync.
+ *
+ * @public
+ * @param queryParam - The URL query parameter name to use (default: 'facets')
+ */
+export function useFacetSelection(queryParam = 'facets') {
+ // Sync with URL query param (stable ref - doesn't change on other query changes)
+ const facetsParam = useRouteQuery(queryParam, '', { mode: 'replace' })
+
+ // Parse facets from URL or use defaults
+ const selectedFacets = computed({
+ get() {
+ if (!facetsParam.value) {
+ return DEFAULT_FACETS
+ }
+
+ // Parse comma-separated facets and filter valid, non-comingSoon ones
+ const parsed = facetsParam.value
+ .split(',')
+ .map(f => f.trim())
+ .filter(
+ (f): f is ComparisonFacet =>
+ ALL_FACETS.includes(f as ComparisonFacet) &&
+ !FACET_INFO[f as ComparisonFacet].comingSoon,
+ )
+
+ return parsed.length > 0 ? parsed : DEFAULT_FACETS
+ },
+ set(facets) {
+ if (facets.length === 0 || arraysEqual(facets, DEFAULT_FACETS)) {
+ // Remove param if using defaults
+ facetsParam.value = ''
+ } else {
+ facetsParam.value = facets.join(',')
+ }
+ },
+ })
+
+ // Check if a facet is selected
+ function isFacetSelected(facet: ComparisonFacet): boolean {
+ return selectedFacets.value.includes(facet)
+ }
+
+ // Toggle a single facet
+ function toggleFacet(facet: ComparisonFacet): void {
+ const current = selectedFacets.value
+ if (current.includes(facet)) {
+ // Don't allow deselecting all facets
+ if (current.length > 1) {
+ selectedFacets.value = current.filter(f => f !== facet)
+ }
+ } else {
+ selectedFacets.value = [...current, facet]
+ }
+ }
+
+ // Get facets in a category (excluding coming soon)
+ function getFacetsInCategory(category: string): ComparisonFacet[] {
+ return ALL_FACETS.filter(f => {
+ const info = FACET_INFO[f]
+ return info.category === category && !info.comingSoon
+ })
+ }
+
+ // Select all facets in a category
+ function selectCategory(category: string): void {
+ const categoryFacets = getFacetsInCategory(category)
+ const current = selectedFacets.value
+ const newFacets = [...new Set([...current, ...categoryFacets])]
+ selectedFacets.value = newFacets
+ }
+
+ // Deselect all facets in a category
+ function deselectCategory(category: string): void {
+ const categoryFacets = getFacetsInCategory(category)
+ const remaining = selectedFacets.value.filter(f => !categoryFacets.includes(f))
+ // Don't allow deselecting all facets
+ if (remaining.length > 0) {
+ selectedFacets.value = remaining
+ }
+ }
+
+ // Select all facets globally
+ function selectAll(): void {
+ selectedFacets.value = DEFAULT_FACETS
+ }
+
+ // Deselect all facets globally (keeps first facet to ensure at least one)
+ function deselectAll(): void {
+ selectedFacets.value = [DEFAULT_FACETS[0]]
+ }
+
+ // Check if all facets are selected
+ const isAllSelected = computed(() => selectedFacets.value.length === DEFAULT_FACETS.length)
+
+ // Check if only one facet is selected (minimum)
+ const isNoneSelected = computed(() => selectedFacets.value.length === 1)
+
+ return {
+ selectedFacets,
+ isFacetSelected,
+ toggleFacet,
+ selectCategory,
+ deselectCategory,
+ selectAll,
+ deselectAll,
+ isAllSelected,
+ isNoneSelected,
+ allFacets: ALL_FACETS,
+ }
+}
+
+// Helper to compare arrays
+function arraysEqual(a: T[], b: T[]): boolean {
+ if (a.length !== b.length) return false
+ const sortedA = [...a].sort()
+ const sortedB = [...b].sort()
+ return sortedA.every((val, i) => val === sortedB[i])
+}
diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts
new file mode 100644
index 00000000..31704747
--- /dev/null
+++ b/app/composables/usePackageComparison.ts
@@ -0,0 +1,335 @@
+import type { MetricValue, ComparisonFacet, ComparisonPackage } from '#shared/types'
+import type { PackageAnalysisResponse } from './usePackageAnalysis'
+
+export interface PackageComparisonData {
+ package: ComparisonPackage
+ downloads?: number
+ /** Package's own unpacked size (from dist.unpackedSize) */
+ packageSize?: number
+ /** Install size data (fetched lazily) */
+ installSize?: {
+ selfSize: number
+ totalSize: number
+ dependencyCount: number
+ }
+ analysis?: PackageAnalysisResponse
+ vulnerabilities?: {
+ count: number
+ severity: { critical: number; high: number; medium: number; low: number }
+ }
+ metadata?: {
+ license?: string
+ lastUpdated?: string
+ engines?: { node?: string; npm?: string }
+ deprecated?: string
+ }
+}
+
+/**
+ * Composable for fetching and comparing multiple packages.
+ *
+ * @public
+ */
+export function usePackageComparison(packageNames: MaybeRefOrGetter) {
+ const packages = computed(() => toValue(packageNames))
+
+ // Reactive state
+ const packagesData = ref<(PackageComparisonData | null)[]>([])
+ const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')
+ const error = ref(null)
+
+ // Track install size loading separately (it's slower)
+ const installSizeLoading = ref(false)
+
+ // Track last fetched packages to avoid redundant fetches
+ let lastFetchedPackages: string[] = []
+
+ // Fetch function
+ async function fetchPackages(names: string[]) {
+ if (names.length === 0) {
+ packagesData.value = []
+ status.value = 'idle'
+ lastFetchedPackages = []
+ return
+ }
+
+ // Skip fetch if packages haven't actually changed
+ if (
+ names.length === lastFetchedPackages.length &&
+ names.every((n, i) => n === lastFetchedPackages[i])
+ ) {
+ return
+ }
+
+ lastFetchedPackages = [...names]
+ status.value = 'pending'
+ error.value = null
+
+ try {
+ // First pass: fetch fast data (package info, downloads, analysis, vulns)
+ const results = await Promise.all(
+ names.map(async (name): Promise => {
+ try {
+ // Fetch basic package info first (required)
+ const pkgData = await $fetch<{
+ 'name': string
+ 'dist-tags': Record
+ 'time': Record
+ 'license'?: string
+ 'versions': Record
+ }>(`https://registry.npmjs.org/${encodePackageName(name)}`)
+
+ const latestVersion = pkgData['dist-tags']?.latest
+ if (!latestVersion) return null
+
+ // Fetch fast additional data in parallel (optional - failures are ok)
+ const [downloads, analysis, vulns] = await Promise.all([
+ $fetch<{ downloads: number }>(
+ `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`,
+ ).catch(() => null),
+ $fetch(`/api/registry/analysis/${name}`).catch(() => null),
+ $fetch<{
+ vulnerabilities: Array<{ severity: string }>
+ }>(`/api/registry/vulnerabilities/${name}`).catch(() => null),
+ ])
+
+ const versionData = pkgData.versions[latestVersion]
+ const packageSize = versionData?.dist?.unpackedSize
+
+ // Count vulnerabilities by severity
+ const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 }
+ const vulnList = vulns?.vulnerabilities ?? []
+ for (const v of vulnList) {
+ const sev = v.severity.toLowerCase() as keyof typeof vulnCounts
+ if (sev in vulnCounts) vulnCounts[sev]++
+ }
+
+ return {
+ package: {
+ name: pkgData.name,
+ version: latestVersion,
+ description: undefined,
+ },
+ downloads: downloads?.downloads,
+ packageSize,
+ installSize: undefined, // Will be filled in second pass
+ analysis: analysis ?? undefined,
+ vulnerabilities: {
+ count: vulnList.length,
+ severity: vulnCounts,
+ },
+ metadata: {
+ license: pkgData.license,
+ lastUpdated: pkgData.time?.modified,
+ engines: analysis?.engines,
+ deprecated: versionData?.deprecated,
+ },
+ }
+ } catch {
+ return null
+ }
+ }),
+ )
+
+ packagesData.value = results
+ status.value = 'success'
+
+ // Second pass: fetch slow install size data in background
+ installSizeLoading.value = true
+ Promise.all(
+ names.map(async (name, index) => {
+ try {
+ const installSize = await $fetch<{
+ selfSize: number
+ totalSize: number
+ dependencyCount: number
+ }>(`/api/registry/install-size/${name}`)
+
+ // Update the specific package's install size
+ if (packagesData.value[index]) {
+ packagesData.value[index] = {
+ ...packagesData.value[index]!,
+ installSize,
+ }
+ }
+ } catch {
+ // Install size fetch failed, leave as undefined
+ }
+ }),
+ ).finally(() => {
+ installSizeLoading.value = false
+ })
+ } catch (e) {
+ error.value = e as Error
+ status.value = 'error'
+ }
+ }
+
+ // Watch for package changes and refetch (client-side only)
+ if (import.meta.client) {
+ watch(
+ packages,
+ newPackages => {
+ fetchPackages(newPackages)
+ },
+ { immediate: true },
+ )
+ }
+
+ // Compute metrics for each facet
+ function getMetricValues(facet: ComparisonFacet): (MetricValue | null)[] {
+ if (!packagesData.value || packagesData.value.length === 0) return []
+
+ return packagesData.value.map(pkg => {
+ if (!pkg) return null
+ return computeMetricValue(facet, pkg)
+ })
+ }
+
+ // Check if a facet depends on slow-loading data
+ function isFacetLoading(facet: ComparisonFacet): boolean {
+ if (!installSizeLoading.value) return false
+ // These facets depend on install-size API
+ return facet === 'installSize' || facet === 'dependencies'
+ }
+
+ return {
+ packagesData: readonly(packagesData),
+ status: readonly(status),
+ error: readonly(error),
+ getMetricValues,
+ isFacetLoading,
+ }
+}
+
+function encodePackageName(name: string): string {
+ if (name.startsWith('@')) {
+ return `@${encodeURIComponent(name.slice(1))}`
+ }
+ return encodeURIComponent(name)
+}
+
+function computeMetricValue(
+ facet: ComparisonFacet,
+ data: PackageComparisonData,
+): MetricValue | null {
+ switch (facet) {
+ case 'downloads':
+ if (data.downloads === undefined) return null
+ return {
+ raw: data.downloads,
+ display: formatCompactNumber(data.downloads),
+ status: 'neutral',
+ }
+
+ case 'packageSize':
+ if (!data.packageSize) return null
+ return {
+ raw: data.packageSize,
+ display: formatBytes(data.packageSize),
+ status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral',
+ }
+
+ case 'installSize':
+ if (!data.installSize) return null
+ return {
+ raw: data.installSize.totalSize,
+ display: formatBytes(data.installSize.totalSize),
+ status: data.installSize.totalSize > 50 * 1024 * 1024 ? 'warning' : 'neutral',
+ }
+
+ case 'moduleFormat':
+ if (!data.analysis) return null
+ const format = data.analysis.moduleFormat
+ return {
+ raw: format,
+ display: format === 'dual' ? 'ESM + CJS' : format.toUpperCase(),
+ status: format === 'esm' || format === 'dual' ? 'good' : 'neutral',
+ }
+
+ case 'types':
+ if (!data.analysis) return null
+ const types = data.analysis.types
+ return {
+ raw: types.kind,
+ display:
+ types.kind === 'included' ? 'Included' : types.kind === '@types' ? '@types' : 'None',
+ status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad',
+ }
+
+ case 'engines':
+ const engines = data.metadata?.engines
+ if (!engines?.node) return { raw: null, display: 'Any', status: 'neutral' }
+ return {
+ raw: engines.node,
+ display: `Node ${engines.node}`,
+ status: 'neutral',
+ }
+
+ case 'vulnerabilities':
+ if (!data.vulnerabilities) return null
+ const count = data.vulnerabilities.count
+ const sev = data.vulnerabilities.severity
+ return {
+ raw: count,
+ display: count === 0 ? 'None' : `${count} (${sev.critical}C/${sev.high}H)`,
+ status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning',
+ }
+
+ case 'lastUpdated':
+ if (!data.metadata?.lastUpdated) return null
+ const date = new Date(data.metadata.lastUpdated)
+ return {
+ raw: date.getTime(),
+ display: data.metadata.lastUpdated,
+ status: isStale(date) ? 'warning' : 'neutral',
+ type: 'date',
+ }
+
+ case 'license':
+ const license = data.metadata?.license
+ if (!license) return { raw: null, display: 'Unknown', status: 'warning' }
+ return {
+ raw: license,
+ display: license,
+ status: 'neutral',
+ }
+
+ case 'dependencies':
+ if (!data.installSize) return null
+ const depCount = data.installSize.dependencyCount
+ return {
+ raw: depCount,
+ display: String(depCount),
+ status: depCount > 50 ? 'warning' : 'neutral',
+ }
+
+ case 'deprecated':
+ const isDeprecated = !!data.metadata?.deprecated
+ return {
+ raw: isDeprecated,
+ display: isDeprecated ? 'Deprecated' : 'No',
+ status: isDeprecated ? 'bad' : 'good',
+ }
+
+ // Coming soon facets
+ case 'totalDependencies':
+ return null
+
+ default:
+ return null
+ }
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function isStale(date: Date): boolean {
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365)
+ return diffYears > 2 // Considered stale if not updated in 2+ years
+}
diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue
index 8817a0ca..dd736383 100644
--- a/app/pages/[...package].vue
+++ b/app/pages/[...package].vue
@@ -468,6 +468,12 @@ onKeyStroke('d', () => {
}
})
+onKeyStroke('c', () => {
+ if (pkg.value) {
+ router.push({ path: '/compare', query: { packages: pkg.value.name } })
+ }
+})
+
defineOgImageComponent('Package', {
name: () => pkg.value?.name ?? 'Package',
version: () => displayVersion.value?.version ?? '',
@@ -612,6 +618,20 @@ function handleClick(event: MouseEvent) {
.
+
+
+ {{ $t('package.links.compare_to') }}
+
+ c
+
+
@@ -738,7 +758,7 @@ function handleClick(event: MouseEvent) {
{{ $t('package.links.fund') }}
-
+
+
+
+
+ {{ $t('package.links.compare_to') }}
+
+
diff --git a/app/pages/compare.vue b/app/pages/compare.vue
new file mode 100644
index 00000000..f0dce1d5
--- /dev/null
+++ b/app/pages/compare.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+ {{ t('compare.packages.section_packages') }}
+
+
+
+
+
+
+
+
+ {{ t('compare.packages.section_facets') }}
+
+
+ /
+
+
+
+
+
+
+
+
+ {{ t('compare.packages.section_comparison') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('compare.packages.error') }}
+
+
+
+
+
+
+ {{ t('compare.packages.empty_title') }}
+
+ {{ t('compare.packages.empty_description') }}
+
+
+
+
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index ef6f4eda..82f19dc8 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -43,6 +43,7 @@
"popular_packages": "Popular packages",
"search": "search",
"settings": "settings",
+ "compare": "compare",
"back": "back"
},
"settings": {
@@ -141,7 +142,9 @@
"jsr": "jsr",
"code": "code",
"docs": "docs",
- "fund": "fund"
+ "fund": "fund",
+ "compare": "compare",
+ "compare_to": "compare to..."
},
"docs": {
"not_available": "Docs not available",
@@ -738,5 +741,68 @@
"empty": "No organizations found",
"view_all": "View all"
}
+ },
+ "compare": {
+ "packages": {
+ "title": "Compare Packages",
+ "tagline": "Compare npm packages side-by-side to help you choose the right one.",
+ "meta_title": "Compare {packages} - npmx",
+ "meta_title_empty": "Compare Packages - npmx",
+ "meta_description": "Side-by-side comparison of {packages}",
+ "meta_description_empty": "Compare npm packages side-by-side",
+ "section_packages": "Packages",
+ "section_facets": "Facets",
+ "section_comparison": "Comparison",
+ "loading": "Loading package data...",
+ "error": "Failed to load package data. Please try again.",
+ "empty_title": "Select packages to compare",
+ "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics."
+ },
+ "releases": {
+ "title": "Compare Versions",
+ "breadcrumb_diff": "diff",
+ "tagline_prefix": "See what changed between",
+ "tagline_separator": "and",
+ "meta_title": "{package} {v1} vs {v2} - npmx",
+ "meta_description": "Compare versions {v1} and {v2} of {package} - see what changed between releases",
+ "from_version": "From version",
+ "to_version": "To version",
+ "swap_versions": "Swap versions",
+ "section_changes": "Changes",
+ "section_file_changes": "File Changes",
+ "section_dependency_changes": "Dependency Changes",
+ "loading": "Loading version data...",
+ "error": "Failed to load version data. Make sure both versions exist.",
+ "file_diff_title": "File Diff Viewer",
+ "file_diff_description": "Visual diff of package files between versions, showing added, removed, and modified files with syntax highlighting.",
+ "dep_diff_title": "Dependency Diff",
+ "dep_diff_description": "See which dependencies were added, removed, or updated between versions, with version constraint changes highlighted."
+ },
+ "selector": {
+ "search_first": "Search for a package...",
+ "search_add": "Add another package...",
+ "searching": "Searching...",
+ "remove_package": "Remove {package}",
+ "packages_selected": "{count}/{max} packages selected.",
+ "add_hint": "Add at least 2 packages to compare.",
+ "loading_versions": "Loading versions...",
+ "select_version": "Select version"
+ },
+ "facets": {
+ "group_label": "Comparison facets",
+ "all": "all",
+ "none": "none",
+ "coming_soon": "Coming soon",
+ "select_all": "Select all facets",
+ "deselect_all": "Deselect all facets",
+ "select_category": "Select all {category} facets",
+ "deselect_category": "Deselect all {category} facets",
+ "categories": {
+ "performance": "Performance",
+ "health": "Health",
+ "compatibility": "Compatibility",
+ "security": "Security & Compliance"
+ }
+ }
}
}
diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json
index 69a061e1..dc8bb4ba 100644
--- a/i18n/locales/fr-FR.json
+++ b/i18n/locales/fr-FR.json
@@ -132,7 +132,9 @@
"jsr": "jsr",
"code": "code",
"docs": "docs",
- "fund": "donner"
+ "fund": "donner",
+ "compare": "comparer",
+ "compare_to": "comparer à..."
},
"docs": {
"not_available": "Documentation non disponible",
@@ -729,5 +731,68 @@
"empty": "Aucune organisation trouvée",
"view_all": "Tout voir"
}
+ },
+ "compare": {
+ "packages": {
+ "title": "Comparer les paquets",
+ "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.",
+ "meta_title": "Comparer {packages} - npmx",
+ "meta_title_empty": "Comparer les paquets - npmx",
+ "meta_description": "Comparaison côte à côte de {packages}",
+ "meta_description_empty": "Comparez les paquets npm côte à côte",
+ "section_packages": "Paquets",
+ "section_facets": "Facettes",
+ "section_comparison": "Comparaison",
+ "loading": "Chargement des données des paquets...",
+ "error": "Échec du chargement des données. Veuillez réessayer.",
+ "empty_title": "Sélectionnez des paquets à comparer",
+ "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs métriques."
+ },
+ "releases": {
+ "title": "Comparer les versions",
+ "breadcrumb_diff": "diff",
+ "tagline_prefix": "Voir ce qui a changé entre",
+ "tagline_separator": "et",
+ "meta_title": "{package} {v1} vs {v2} - npmx",
+ "meta_description": "Comparez les versions {v1} et {v2} de {package} - voyez ce qui a changé entre les versions",
+ "from_version": "De la version",
+ "to_version": "Vers la version",
+ "swap_versions": "Inverser les versions",
+ "section_changes": "Changements",
+ "section_file_changes": "Changements de fichiers",
+ "section_dependency_changes": "Changements de dépendances",
+ "loading": "Chargement des données de version...",
+ "error": "Échec du chargement des données de version. Vérifiez que les deux versions existent.",
+ "file_diff_title": "Visualiseur de différences de fichiers",
+ "file_diff_description": "Différence visuelle des fichiers du paquet entre les versions, montrant les fichiers ajoutés, supprimés et modifiés avec coloration syntaxique.",
+ "dep_diff_title": "Différence de dépendances",
+ "dep_diff_description": "Voyez quelles dépendances ont été ajoutées, supprimées ou mises à jour entre les versions, avec les changements de contraintes de version mis en évidence."
+ },
+ "selector": {
+ "search_first": "Rechercher un paquet...",
+ "search_add": "Ajouter un autre paquet...",
+ "searching": "Recherche...",
+ "remove_package": "Supprimer {package}",
+ "packages_selected": "{count}/{max} paquets sélectionnés.",
+ "add_hint": "Ajoutez au moins 2 paquets à comparer.",
+ "loading_versions": "Chargement des versions...",
+ "select_version": "Sélectionner une version"
+ },
+ "facets": {
+ "group_label": "Facettes de comparaison",
+ "all": "tout",
+ "none": "aucun",
+ "coming_soon": "Bientôt disponible",
+ "select_all": "Sélectionner toutes les facettes",
+ "deselect_all": "Désélectionner toutes les facettes",
+ "select_category": "Sélectionner toutes les facettes {category}",
+ "deselect_category": "Désélectionner toutes les facettes {category}",
+ "categories": {
+ "performance": "Performance",
+ "health": "Santé",
+ "compatibility": "Compatibilité",
+ "security": "Sécurité & Conformité"
+ }
+ }
}
}
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index ef6f4eda..82f19dc8 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -43,6 +43,7 @@
"popular_packages": "Popular packages",
"search": "search",
"settings": "settings",
+ "compare": "compare",
"back": "back"
},
"settings": {
@@ -141,7 +142,9 @@
"jsr": "jsr",
"code": "code",
"docs": "docs",
- "fund": "fund"
+ "fund": "fund",
+ "compare": "compare",
+ "compare_to": "compare to..."
},
"docs": {
"not_available": "Docs not available",
@@ -738,5 +741,68 @@
"empty": "No organizations found",
"view_all": "View all"
}
+ },
+ "compare": {
+ "packages": {
+ "title": "Compare Packages",
+ "tagline": "Compare npm packages side-by-side to help you choose the right one.",
+ "meta_title": "Compare {packages} - npmx",
+ "meta_title_empty": "Compare Packages - npmx",
+ "meta_description": "Side-by-side comparison of {packages}",
+ "meta_description_empty": "Compare npm packages side-by-side",
+ "section_packages": "Packages",
+ "section_facets": "Facets",
+ "section_comparison": "Comparison",
+ "loading": "Loading package data...",
+ "error": "Failed to load package data. Please try again.",
+ "empty_title": "Select packages to compare",
+ "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics."
+ },
+ "releases": {
+ "title": "Compare Versions",
+ "breadcrumb_diff": "diff",
+ "tagline_prefix": "See what changed between",
+ "tagline_separator": "and",
+ "meta_title": "{package} {v1} vs {v2} - npmx",
+ "meta_description": "Compare versions {v1} and {v2} of {package} - see what changed between releases",
+ "from_version": "From version",
+ "to_version": "To version",
+ "swap_versions": "Swap versions",
+ "section_changes": "Changes",
+ "section_file_changes": "File Changes",
+ "section_dependency_changes": "Dependency Changes",
+ "loading": "Loading version data...",
+ "error": "Failed to load version data. Make sure both versions exist.",
+ "file_diff_title": "File Diff Viewer",
+ "file_diff_description": "Visual diff of package files between versions, showing added, removed, and modified files with syntax highlighting.",
+ "dep_diff_title": "Dependency Diff",
+ "dep_diff_description": "See which dependencies were added, removed, or updated between versions, with version constraint changes highlighted."
+ },
+ "selector": {
+ "search_first": "Search for a package...",
+ "search_add": "Add another package...",
+ "searching": "Searching...",
+ "remove_package": "Remove {package}",
+ "packages_selected": "{count}/{max} packages selected.",
+ "add_hint": "Add at least 2 packages to compare.",
+ "loading_versions": "Loading versions...",
+ "select_version": "Select version"
+ },
+ "facets": {
+ "group_label": "Comparison facets",
+ "all": "all",
+ "none": "none",
+ "coming_soon": "Coming soon",
+ "select_all": "Select all facets",
+ "deselect_all": "Deselect all facets",
+ "select_category": "Select all {category} facets",
+ "deselect_category": "Deselect all {category} facets",
+ "categories": {
+ "performance": "Performance",
+ "health": "Health",
+ "compatibility": "Compatibility",
+ "security": "Security & Compliance"
+ }
+ }
}
}
diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json
index 69a061e1..dc8bb4ba 100644
--- a/lunaria/files/fr-FR.json
+++ b/lunaria/files/fr-FR.json
@@ -132,7 +132,9 @@
"jsr": "jsr",
"code": "code",
"docs": "docs",
- "fund": "donner"
+ "fund": "donner",
+ "compare": "comparer",
+ "compare_to": "comparer à..."
},
"docs": {
"not_available": "Documentation non disponible",
@@ -729,5 +731,68 @@
"empty": "Aucune organisation trouvée",
"view_all": "Tout voir"
}
+ },
+ "compare": {
+ "packages": {
+ "title": "Comparer les paquets",
+ "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.",
+ "meta_title": "Comparer {packages} - npmx",
+ "meta_title_empty": "Comparer les paquets - npmx",
+ "meta_description": "Comparaison côte à côte de {packages}",
+ "meta_description_empty": "Comparez les paquets npm côte à côte",
+ "section_packages": "Paquets",
+ "section_facets": "Facettes",
+ "section_comparison": "Comparaison",
+ "loading": "Chargement des données des paquets...",
+ "error": "Échec du chargement des données. Veuillez réessayer.",
+ "empty_title": "Sélectionnez des paquets à comparer",
+ "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs métriques."
+ },
+ "releases": {
+ "title": "Comparer les versions",
+ "breadcrumb_diff": "diff",
+ "tagline_prefix": "Voir ce qui a changé entre",
+ "tagline_separator": "et",
+ "meta_title": "{package} {v1} vs {v2} - npmx",
+ "meta_description": "Comparez les versions {v1} et {v2} de {package} - voyez ce qui a changé entre les versions",
+ "from_version": "De la version",
+ "to_version": "Vers la version",
+ "swap_versions": "Inverser les versions",
+ "section_changes": "Changements",
+ "section_file_changes": "Changements de fichiers",
+ "section_dependency_changes": "Changements de dépendances",
+ "loading": "Chargement des données de version...",
+ "error": "Échec du chargement des données de version. Vérifiez que les deux versions existent.",
+ "file_diff_title": "Visualiseur de différences de fichiers",
+ "file_diff_description": "Différence visuelle des fichiers du paquet entre les versions, montrant les fichiers ajoutés, supprimés et modifiés avec coloration syntaxique.",
+ "dep_diff_title": "Différence de dépendances",
+ "dep_diff_description": "Voyez quelles dépendances ont été ajoutées, supprimées ou mises à jour entre les versions, avec les changements de contraintes de version mis en évidence."
+ },
+ "selector": {
+ "search_first": "Rechercher un paquet...",
+ "search_add": "Ajouter un autre paquet...",
+ "searching": "Recherche...",
+ "remove_package": "Supprimer {package}",
+ "packages_selected": "{count}/{max} paquets sélectionnés.",
+ "add_hint": "Ajoutez au moins 2 paquets à comparer.",
+ "loading_versions": "Chargement des versions...",
+ "select_version": "Sélectionner une version"
+ },
+ "facets": {
+ "group_label": "Facettes de comparaison",
+ "all": "tout",
+ "none": "aucun",
+ "coming_soon": "Bientôt disponible",
+ "select_all": "Sélectionner toutes les facettes",
+ "deselect_all": "Désélectionner toutes les facettes",
+ "select_category": "Sélectionner toutes les facettes {category}",
+ "deselect_category": "Désélectionner toutes les facettes {category}",
+ "categories": {
+ "performance": "Performance",
+ "health": "Santé",
+ "compatibility": "Compatibilité",
+ "security": "Sécurité & Conformité"
+ }
+ }
}
}
diff --git a/package.json b/package.json
index c97ecf95..ace9a351 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@shikijs/themes": "^3.21.0",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "14.1.0",
+ "@vueuse/router": "^14.1.0",
"module-replacements": "^2.11.0",
"nuxt": "^4.3.0",
"nuxt-og-image": "^5.1.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0d26cc82..f062b9b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,9 @@ importers:
'@vueuse/nuxt':
specifier: 14.1.0
version: 14.1.0(magicast@0.5.1)(nuxt@4.3.0(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.5.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.5.0))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.2))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.2(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
+ '@vueuse/router':
+ specifier: ^14.1.0
+ version: 14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
module-replacements:
specifier: ^2.11.0
version: 2.11.0
@@ -4285,6 +4288,12 @@ packages:
nuxt: ^3.0.0 || ^4.0.0-0
vue: ^3.5.0
+ '@vueuse/router@14.1.0':
+ resolution: {integrity: sha512-8h7g0PhjcMC2Vnu9zBkN1J038JFIzkS3/DP2L5ouzFEhY3YAM8zkIOZ0K+hzAWkYEFLGmWGcgBfuvCUD0U42Jw==}
+ peerDependencies:
+ vue: ^3.5.0
+ vue-router: ^4.0.0
+
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
@@ -13801,6 +13810,12 @@ snapshots:
transitivePeerDependencies:
- magicast
+ '@vueuse/router@14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))':
+ dependencies:
+ '@vueuse/shared': 14.1.0(vue@3.5.27(typescript@5.9.3))
+ vue: 3.5.27(typescript@5.9.3)
+ vue-router: 4.6.4(vue@3.5.27(typescript@5.9.3))
+
'@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3))
diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts
new file mode 100644
index 00000000..62ae459b
--- /dev/null
+++ b/shared/types/comparison.ts
@@ -0,0 +1,151 @@
+/**
+ * Comparison feature types
+ */
+
+/** Available comparison facets/metrics */
+export type ComparisonFacet =
+ | 'downloads'
+ | 'packageSize'
+ | 'installSize'
+ | 'moduleFormat'
+ | 'types'
+ | 'engines'
+ | 'vulnerabilities'
+ | 'lastUpdated'
+ | 'license'
+ | 'dependencies'
+ | 'totalDependencies'
+ | 'deprecated'
+
+/** Facet metadata for UI display */
+export interface FacetInfo {
+ id: ComparisonFacet
+ label: string
+ description: string
+ category: 'performance' | 'health' | 'compatibility' | 'security'
+ comingSoon?: boolean
+}
+
+/** Category display order */
+export const CATEGORY_ORDER = ['performance', 'health', 'compatibility', 'security'] as const
+
+/** All available facets with their metadata (ordered by category, then display order within category) */
+export const FACET_INFO: Record> = {
+ // Performance
+ packageSize: {
+ label: 'Package Size',
+ description: 'Size of the package itself (unpacked)',
+ category: 'performance',
+ },
+ installSize: {
+ label: 'Install Size',
+ description: 'Total install size including all dependencies',
+ category: 'performance',
+ },
+ dependencies: {
+ label: '# Direct Deps',
+ description: 'Number of direct dependencies',
+ category: 'performance',
+ },
+ totalDependencies: {
+ label: '# Total Deps',
+ description: 'Total number of dependencies including transitive',
+ category: 'performance',
+ comingSoon: true,
+ },
+ // Health
+ downloads: {
+ label: 'Downloads/wk',
+ description: 'Weekly download count',
+ category: 'health',
+ },
+ lastUpdated: {
+ label: 'Last Updated',
+ description: 'Most recent publish date',
+ category: 'health',
+ },
+ deprecated: {
+ label: 'Deprecated?',
+ description: 'Whether the package is deprecated',
+ category: 'health',
+ },
+ // Compatibility
+ engines: {
+ label: 'Engines',
+ description: 'Node.js version requirements',
+ category: 'compatibility',
+ },
+ types: {
+ label: 'Types',
+ description: 'TypeScript type definitions',
+ category: 'compatibility',
+ },
+ moduleFormat: {
+ label: 'Module Format',
+ description: 'ESM/CJS support',
+ category: 'compatibility',
+ },
+ // Security
+ license: {
+ label: 'License',
+ description: 'Package license',
+ category: 'security',
+ },
+ vulnerabilities: {
+ label: 'Vulnerabilities',
+ description: 'Known security vulnerabilities',
+ category: 'security',
+ },
+}
+
+/** All facets in display order */
+export const ALL_FACETS: ComparisonFacet[] = Object.keys(FACET_INFO) as ComparisonFacet[]
+
+/** Facets grouped by category (derived from FACET_INFO) */
+export const FACETS_BY_CATEGORY: Record =
+ ALL_FACETS.reduce(
+ (acc, facet) => {
+ acc[FACET_INFO[facet].category].push(facet)
+ return acc
+ },
+ { performance: [], health: [], compatibility: [], security: [] } as Record<
+ FacetInfo['category'],
+ ComparisonFacet[]
+ >,
+ )
+
+/** Default facets - all non-comingSoon facets */
+export const DEFAULT_FACETS: ComparisonFacet[] = ALL_FACETS.filter(f => !FACET_INFO[f].comingSoon)
+
+/** Metric value that can be compared */
+export interface MetricValue {
+ /** Raw value for comparison logic */
+ raw: T
+ /** Formatted display string (or ISO date string if type is 'date') */
+ display: string
+ /** Optional status indicator */
+ status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral'
+ /** Value type for special rendering (e.g., dates use DateTime component) */
+ type?: 'date'
+}
+
+/** Result of comparing two metric values */
+export interface DiffResult<_T = unknown> {
+ /** Absolute difference (for numeric values) */
+ absoluteDiff?: number
+ /** Percentage difference (for numeric values) */
+ percentDiff?: number
+ /** Human-readable difference string */
+ display: string
+ /** Direction of change */
+ direction: 'increase' | 'decrease' | 'same' | 'changed'
+ /** Whether the change is favorable (depends on metric semantics) */
+ favorable?: boolean
+}
+
+/** Package data for comparison */
+export interface ComparisonPackage {
+ name: string
+ version: string
+ description?: string
+}
diff --git a/shared/types/index.ts b/shared/types/index.ts
index 1bb0a8a1..0459b691 100644
--- a/shared/types/index.ts
+++ b/shared/types/index.ts
@@ -5,3 +5,4 @@ export * from './readme'
export * from './docs'
export * from './deno-doc'
export * from './i18n-status'
+export * from './comparison'
diff --git a/test/nuxt/composables/use-facet-selection.spec.ts b/test/nuxt/composables/use-facet-selection.spec.ts
new file mode 100644
index 00000000..7d8b2906
--- /dev/null
+++ b/test/nuxt/composables/use-facet-selection.spec.ts
@@ -0,0 +1,230 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { DEFAULT_FACETS, FACETS_BY_CATEGORY } from '../../../shared/types/comparison'
+
+// Mock useRouteQuery
+const mockRouteQuery = ref('')
+vi.mock('@vueuse/router', () => ({
+ useRouteQuery: () => mockRouteQuery,
+}))
+
+describe('useFacetSelection', () => {
+ beforeEach(() => {
+ mockRouteQuery.value = ''
+ })
+
+ it('returns DEFAULT_FACETS when no query param', () => {
+ const { selectedFacets } = useFacetSelection()
+
+ expect(selectedFacets.value).toEqual(DEFAULT_FACETS)
+ })
+
+ it('parses facets from query param', () => {
+ mockRouteQuery.value = 'downloads,types,license'
+
+ const { selectedFacets } = useFacetSelection()
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).toContain('types')
+ expect(selectedFacets.value).toContain('license')
+ })
+
+ it('filters out invalid facets from query', () => {
+ mockRouteQuery.value = 'downloads,invalidFacet,types'
+
+ const { selectedFacets } = useFacetSelection()
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).toContain('types')
+ expect(selectedFacets.value).not.toContain('invalidFacet')
+ })
+
+ it('filters out comingSoon facets from query', () => {
+ mockRouteQuery.value = 'downloads,totalDependencies,types'
+
+ const { selectedFacets } = useFacetSelection()
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).toContain('types')
+ expect(selectedFacets.value).not.toContain('totalDependencies')
+ })
+
+ it('falls back to DEFAULT_FACETS if all parsed facets are invalid', () => {
+ mockRouteQuery.value = 'invalidFacet1,invalidFacet2'
+
+ const { selectedFacets } = useFacetSelection()
+
+ expect(selectedFacets.value).toEqual(DEFAULT_FACETS)
+ })
+
+ describe('isFacetSelected', () => {
+ it('returns true for selected facets', () => {
+ mockRouteQuery.value = 'downloads,types'
+
+ const { isFacetSelected } = useFacetSelection()
+
+ expect(isFacetSelected('downloads')).toBe(true)
+ expect(isFacetSelected('types')).toBe(true)
+ })
+
+ it('returns false for unselected facets', () => {
+ mockRouteQuery.value = 'downloads,types'
+
+ const { isFacetSelected } = useFacetSelection()
+
+ expect(isFacetSelected('license')).toBe(false)
+ expect(isFacetSelected('engines')).toBe(false)
+ })
+ })
+
+ describe('toggleFacet', () => {
+ it('adds facet when not selected', () => {
+ mockRouteQuery.value = 'downloads'
+
+ const { selectedFacets, toggleFacet } = useFacetSelection()
+
+ toggleFacet('types')
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).toContain('types')
+ })
+
+ it('removes facet when selected', () => {
+ mockRouteQuery.value = 'downloads,types'
+
+ const { selectedFacets, toggleFacet } = useFacetSelection()
+
+ toggleFacet('types')
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).not.toContain('types')
+ })
+
+ it('does not remove last facet', () => {
+ mockRouteQuery.value = 'downloads'
+
+ const { selectedFacets, toggleFacet } = useFacetSelection()
+
+ toggleFacet('downloads')
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value.length).toBe(1)
+ })
+ })
+
+ describe('selectCategory', () => {
+ it('selects all facets in a category', () => {
+ mockRouteQuery.value = 'downloads'
+
+ const { selectedFacets, selectCategory } = useFacetSelection()
+
+ selectCategory('performance')
+
+ const performanceFacets = FACETS_BY_CATEGORY.performance.filter(
+ f => f !== 'totalDependencies', // comingSoon facet
+ )
+ for (const facet of performanceFacets) {
+ expect(selectedFacets.value).toContain(facet)
+ }
+ })
+
+ it('preserves existing selections from other categories', () => {
+ mockRouteQuery.value = 'downloads,license'
+
+ const { selectedFacets, selectCategory } = useFacetSelection()
+
+ selectCategory('compatibility')
+
+ expect(selectedFacets.value).toContain('downloads')
+ expect(selectedFacets.value).toContain('license')
+ })
+ })
+
+ describe('deselectCategory', () => {
+ it('deselects all facets in a category', () => {
+ mockRouteQuery.value = ''
+ const { selectedFacets, deselectCategory } = useFacetSelection()
+
+ deselectCategory('performance')
+
+ const nonComingSoonPerformanceFacets = FACETS_BY_CATEGORY.performance.filter(
+ f => f !== 'totalDependencies',
+ )
+ for (const facet of nonComingSoonPerformanceFacets) {
+ expect(selectedFacets.value).not.toContain(facet)
+ }
+ })
+
+ it('does not deselect if it would leave no facets', () => {
+ mockRouteQuery.value = 'packageSize,installSize'
+
+ const { selectedFacets, deselectCategory } = useFacetSelection()
+
+ deselectCategory('performance')
+
+ // Should still have at least one facet
+ expect(selectedFacets.value.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('selectAll', () => {
+ it('selects all default facets', () => {
+ mockRouteQuery.value = 'downloads'
+
+ const { selectedFacets, selectAll } = useFacetSelection()
+
+ selectAll()
+
+ expect(selectedFacets.value).toEqual(DEFAULT_FACETS)
+ })
+ })
+
+ describe('deselectAll', () => {
+ it('keeps only the first default facet', () => {
+ mockRouteQuery.value = ''
+
+ const { selectedFacets, deselectAll } = useFacetSelection()
+
+ deselectAll()
+
+ expect(selectedFacets.value).toHaveLength(1)
+ expect(selectedFacets.value[0]).toBe(DEFAULT_FACETS[0])
+ })
+ })
+
+ describe('isAllSelected', () => {
+ it('returns true when all facets selected', () => {
+ mockRouteQuery.value = ''
+
+ const { isAllSelected } = useFacetSelection()
+
+ expect(isAllSelected.value).toBe(true)
+ })
+
+ it('returns false when not all facets selected', () => {
+ mockRouteQuery.value = 'downloads,types'
+
+ const { isAllSelected } = useFacetSelection()
+
+ expect(isAllSelected.value).toBe(false)
+ })
+ })
+
+ describe('isNoneSelected', () => {
+ it('returns true when only one facet selected', () => {
+ mockRouteQuery.value = 'downloads'
+
+ const { isNoneSelected } = useFacetSelection()
+
+ expect(isNoneSelected.value).toBe(true)
+ })
+
+ it('returns false when multiple facets selected', () => {
+ mockRouteQuery.value = 'downloads,types'
+
+ const { isNoneSelected } = useFacetSelection()
+
+ expect(isNoneSelected.value).toBe(false)
+ })
+ })
+})
diff --git a/test/unit/comparison.spec.ts b/test/unit/comparison.spec.ts
new file mode 100644
index 00000000..c00d06eb
--- /dev/null
+++ b/test/unit/comparison.spec.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest'
+import {
+ ALL_FACETS,
+ CATEGORY_ORDER,
+ DEFAULT_FACETS,
+ FACET_INFO,
+ FACETS_BY_CATEGORY,
+ type ComparisonFacet,
+} from '../../shared/types/comparison'
+
+describe('comparison types', () => {
+ describe('FACET_INFO', () => {
+ it('defines all expected facets', () => {
+ const expectedFacets: ComparisonFacet[] = [
+ 'packageSize',
+ 'installSize',
+ 'dependencies',
+ 'totalDependencies',
+ 'downloads',
+ 'lastUpdated',
+ 'deprecated',
+ 'engines',
+ 'types',
+ 'moduleFormat',
+ 'license',
+ 'vulnerabilities',
+ ]
+
+ for (const facet of expectedFacets) {
+ expect(FACET_INFO[facet]).toBeDefined()
+ expect(FACET_INFO[facet].label).toBeTruthy()
+ expect(FACET_INFO[facet].description).toBeTruthy()
+ expect(FACET_INFO[facet].category).toBeTruthy()
+ }
+ })
+
+ it('has valid categories for all facets', () => {
+ const validCategories = ['performance', 'health', 'compatibility', 'security']
+
+ for (const facet of ALL_FACETS) {
+ expect(validCategories).toContain(FACET_INFO[facet].category)
+ }
+ })
+
+ it('marks totalDependencies as comingSoon', () => {
+ expect(FACET_INFO.totalDependencies.comingSoon).toBe(true)
+ })
+ })
+
+ describe('CATEGORY_ORDER', () => {
+ it('defines categories in correct order', () => {
+ expect(CATEGORY_ORDER).toEqual(['performance', 'health', 'compatibility', 'security'])
+ })
+ })
+
+ describe('ALL_FACETS', () => {
+ it('contains all facets from FACET_INFO', () => {
+ const facetInfoKeys = Object.keys(FACET_INFO) as ComparisonFacet[]
+ expect(ALL_FACETS).toHaveLength(facetInfoKeys.length)
+ for (const facet of facetInfoKeys) {
+ expect(ALL_FACETS).toContain(facet)
+ }
+ })
+
+ it('maintains order grouped by category', () => {
+ // First facets should be performance
+ const performanceFacets = FACETS_BY_CATEGORY.performance
+ expect(ALL_FACETS.slice(0, performanceFacets.length)).toEqual(performanceFacets)
+ })
+ })
+
+ describe('DEFAULT_FACETS', () => {
+ it('excludes comingSoon facets', () => {
+ for (const facet of DEFAULT_FACETS) {
+ expect(FACET_INFO[facet].comingSoon).not.toBe(true)
+ }
+ })
+
+ it('includes all non-comingSoon facets', () => {
+ const nonComingSoonFacets = ALL_FACETS.filter(f => !FACET_INFO[f].comingSoon)
+ expect(DEFAULT_FACETS).toEqual(nonComingSoonFacets)
+ })
+
+ it('does not include totalDependencies', () => {
+ expect(DEFAULT_FACETS).not.toContain('totalDependencies')
+ })
+ })
+
+ describe('FACETS_BY_CATEGORY', () => {
+ it('groups all facets by their category', () => {
+ for (const category of CATEGORY_ORDER) {
+ const facetsInCategory = FACETS_BY_CATEGORY[category]
+ expect(facetsInCategory.length).toBeGreaterThan(0)
+
+ for (const facet of facetsInCategory) {
+ expect(FACET_INFO[facet].category).toBe(category)
+ }
+ }
+ })
+
+ it('contains all facets exactly once', () => {
+ const allFacetsFromCategories = CATEGORY_ORDER.flatMap(cat => FACETS_BY_CATEGORY[cat])
+ expect(allFacetsFromCategories).toHaveLength(ALL_FACETS.length)
+ expect(new Set(allFacetsFromCategories).size).toBe(ALL_FACETS.length)
+ })
+
+ it('performance category has size and dependency facets', () => {
+ expect(FACETS_BY_CATEGORY.performance).toContain('packageSize')
+ expect(FACETS_BY_CATEGORY.performance).toContain('installSize')
+ expect(FACETS_BY_CATEGORY.performance).toContain('dependencies')
+ })
+
+ it('health category has downloads and update facets', () => {
+ expect(FACETS_BY_CATEGORY.health).toContain('downloads')
+ expect(FACETS_BY_CATEGORY.health).toContain('lastUpdated')
+ expect(FACETS_BY_CATEGORY.health).toContain('deprecated')
+ })
+
+ it('compatibility category has module and type facets', () => {
+ expect(FACETS_BY_CATEGORY.compatibility).toContain('moduleFormat')
+ expect(FACETS_BY_CATEGORY.compatibility).toContain('types')
+ expect(FACETS_BY_CATEGORY.compatibility).toContain('engines')
+ })
+
+ it('security category has license and vulnerability facets', () => {
+ expect(FACETS_BY_CATEGORY.security).toContain('license')
+ expect(FACETS_BY_CATEGORY.security).toContain('vulnerabilities')
+ })
+ })
+})