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" > + + +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 @@ + + + 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 @@ + + + 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 @@ + + + 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) { . + + @@ -738,7 +758,7 @@ function handleClick(event: MouseEvent) { {{ $t('package.links.fund') }} - +
  • +
  • + + +
  • 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 @@ + + + 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') + }) + }) +})