[READ-ONLY] a fast, modern browser for the npm registry

feat(ui): improve compare types in FacetRow for CLI package (#690)

Co-authored-by: Yevhen Husak <gusa4grr@users.noreply.github.com>
Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Yevhen Husak
Yevhen Husak
Daniel Roe
autofix-ci[bot]
and committed by
GitHub
3ec42e09 6d0b51ba

+96 -64
+17 -9
app/components/compare/FacetRow.vue
··· 48 48 return 'text-amber-400' 49 49 case 'bad': 50 50 return 'text-red-400' 51 + case 'muted': 52 + return 'text-fg-subtle' 51 53 default: 52 54 return 'text-fg' 53 55 } ··· 62 64 <template> 63 65 <div class="contents"> 64 66 <!-- Label cell --> 65 - <div 66 - class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border" 67 - :title="description" 68 - > 67 + <div class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border"> 69 68 <span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span> 70 - <span 71 - v-if="description" 72 - class="i-carbon:information w-3 h-3 text-fg-subtle" 73 - aria-hidden="true" 74 - /> 69 + <TooltipApp v-if="description" :text="description" position="top"> 70 + <span class="i-carbon:information w-3 h-3 text-fg-subtle cursor-help" aria-hidden="true" /> 71 + </TooltipApp> 75 72 </div> 76 73 77 74 <!-- Value cells --> ··· 103 100 104 101 <!-- Value display --> 105 102 <template v-else> 103 + <TooltipApp v-if="value.tooltip" :text="value.tooltip" position="top"> 104 + <span 105 + class="relative font-mono text-sm text-center tabular-nums cursor-help" 106 + :class="getStatusClass(value.status)" 107 + > 108 + <!-- Date values use DateTime component for i18n and user settings --> 109 + <DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" /> 110 + <template v-else>{{ value.display }}</template> 111 + </span> 112 + </TooltipApp> 106 113 <span 114 + v-else 107 115 class="relative font-mono text-sm text-center tabular-nums" 108 116 :class="getStatusClass(value.status)" 109 117 >
+32 -10
app/composables/usePackageComparison.ts
··· 1 - import type { FacetValue, ComparisonFacet, ComparisonPackage } from '#shared/types' 1 + import type { FacetValue, ComparisonFacet, ComparisonPackage, Packument } from '#shared/types' 2 2 import { encodePackageName } from '#shared/utils/npm' 3 3 import type { PackageAnalysisResponse } from './usePackageAnalysis' 4 + import { isBinaryOnlyPackage } from '#shared/utils/binary-detection' 4 5 5 6 export interface PackageComparisonData { 6 7 package: ComparisonPackage ··· 24 25 engines?: { node?: string; npm?: string } 25 26 deprecated?: string 26 27 } 28 + /** Whether this is a binary-only package (CLI without library entry points) */ 29 + isBinaryOnly?: boolean 27 30 } 28 31 29 32 /** ··· 31 34 * 32 35 */ 33 36 export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) { 37 + const { t } = useI18n() 34 38 const packages = computed(() => toValue(packageNames)) 35 39 36 40 // Cache of fetched data by package name (source of truth) ··· 75 79 namesToFetch.map(async (name): Promise<PackageComparisonData | null> => { 76 80 try { 77 81 // Fetch basic package info first (required) 78 - const pkgData = await $fetch<{ 79 - 'name': string 80 - 'dist-tags': Record<string, string> 81 - 'time': Record<string, string> 82 - 'license'?: string 83 - 'versions': Record<string, { dist?: { unpackedSize?: number }; deprecated?: string }> 84 - }>(`https://registry.npmjs.org/${encodePackageName(name)}`) 82 + const pkgData = await $fetch<Packument>( 83 + `https://registry.npmjs.org/${encodePackageName(name)}`, 84 + ) 85 85 86 86 const latestVersion = pkgData['dist-tags']?.latest 87 87 if (!latestVersion) return null ··· 100 100 const versionData = pkgData.versions[latestVersion] 101 101 const packageSize = versionData?.dist?.unpackedSize 102 102 103 + // Detect if package is binary-only 104 + const isBinary = isBinaryOnlyPackage({ 105 + name: pkgData.name, 106 + bin: versionData?.bin, 107 + main: versionData?.main, 108 + module: versionData?.module, 109 + exports: versionData?.exports, 110 + }) 111 + 103 112 // Count vulnerabilities by severity 104 113 const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 } 105 114 const vulnList = vulns?.vulnerabilities ?? [] ··· 128 137 engines: analysis?.engines, 129 138 deprecated: versionData?.deprecated, 130 139 }, 140 + isBinaryOnly: isBinary, 131 141 } 132 142 } catch { 133 143 return null ··· 196 206 197 207 return packagesData.value.map(pkg => { 198 208 if (!pkg) return null 199 - return computeFacetValue(facet, pkg) 209 + return computeFacetValue(facet, pkg, t) 200 210 }) 201 211 } 202 212 ··· 223 233 } 224 234 } 225 235 226 - function computeFacetValue(facet: ComparisonFacet, data: PackageComparisonData): FacetValue | null { 236 + function computeFacetValue( 237 + facet: ComparisonFacet, 238 + data: PackageComparisonData, 239 + t: (key: string) => string, 240 + ): FacetValue | null { 227 241 switch (facet) { 228 242 case 'downloads': 229 243 if (data.downloads === undefined) return null ··· 259 273 } 260 274 261 275 case 'types': 276 + if (data.isBinaryOnly) { 277 + return { 278 + raw: 'binary', 279 + display: 'N/A', 280 + status: 'muted', 281 + tooltip: t('compare.facets.binary_only_tooltip'), 282 + } 283 + } 262 284 if (!data.analysis) return null 263 285 const types = data.analysis.types 264 286 return {
-42
app/utils/run-command.ts
··· 3 3 import type { PackageManagerId } from './install-command' 4 4 5 5 /** 6 - * Metadata needed to determine if a package is binary-only. 7 - */ 8 - export interface PackageMetadata { 9 - name: string 10 - bin?: string | Record<string, string> 11 - main?: string 12 - module?: unknown 13 - exports?: unknown 14 - } 15 - 16 - /** 17 - * Determine if a package is "binary-only" (executable without library entry points). 18 - * Binary-only packages should show execute commands without install commands. 19 - * 20 - * A package is binary-only if: 21 - * - Name starts with "create-" (e.g., create-vite) 22 - * - Scoped name contains "/create-" (e.g., @vue/create-app) 23 - * - Has bin field but no main, module, or exports fields 24 - */ 25 - export function isBinaryOnlyPackage(pkg: PackageMetadata): boolean { 26 - // Check create-* patterns 27 - if (isCreatePackage(pkg.name)) { 28 - return true 29 - } 30 - 31 - // Has bin but no entry points 32 - const hasBin = 33 - pkg.bin !== undefined && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0) 34 - const hasEntryPoint = !!pkg.main || !!pkg.module || !!pkg.exports 35 - 36 - return hasBin && !hasEntryPoint 37 - } 38 - 39 - /** 40 - * Check if a package uses the create-* naming convention. 41 - */ 42 - export function isCreatePackage(packageName: string): boolean { 43 - const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName 44 - return baseName?.startsWith('create-') || packageName.includes('/create-') || false 45 - } 46 - 47 - /** 48 6 * Information about executable commands provided by a package. 49 7 */ 50 8 export interface ExecutableInfo {
+1
i18n/locales/en.json
··· 852 852 "deselect_all": "Deselect all facets", 853 853 "select_category": "Select all {category} facets", 854 854 "deselect_category": "Deselect all {category} facets", 855 + "binary_only_tooltip": "This package exposes binaries and no exports", 855 856 "categories": { 856 857 "performance": "Performance", 857 858 "health": "Health",
+1
lunaria/files/en-US.json
··· 852 852 "deselect_all": "Deselect all facets", 853 853 "select_category": "Select all {category} facets", 854 854 "deselect_category": "Deselect all {category} facets", 855 + "binary_only_tooltip": "This package exposes binaries and no exports", 855 856 "categories": { 856 857 "performance": "Performance", 857 858 "health": "Health",
+3 -1
shared/types/comparison.ts
··· 124 124 /** Formatted display string (or ISO date string if type is 'date') */ 125 125 display: string 126 126 /** Optional status indicator */ 127 - status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral' 127 + status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral' | 'muted' 128 128 /** Value type for special rendering (e.g., dates use DateTime component) */ 129 129 type?: 'date' 130 + /** Optional tooltip text to explain the value */ 131 + tooltip?: string 130 132 } 131 133 132 134 /** Package data for comparison */
+41
shared/utils/binary-detection.ts
··· 1 + /** 2 + * Metadata needed to determine if a package is binary-only. 3 + */ 4 + export interface PackageMetadata { 5 + name: string 6 + bin?: string | Record<string, string> 7 + main?: string 8 + module?: unknown 9 + exports?: unknown 10 + } 11 + 12 + /** 13 + * Determine if a package is "binary-only" (executable without library entry points). 14 + * Binary-only packages should show execute commands without install commands. 15 + * 16 + * A package is binary-only if: 17 + * - Name starts with "create-" (e.g., create-vite) 18 + * - Scoped name contains "/create-" (e.g., @vue/create-app) 19 + * - Has bin field but no main, module, or exports fields 20 + */ 21 + export function isBinaryOnlyPackage(pkg: PackageMetadata): boolean { 22 + // Check create-* patterns 23 + if (isCreatePackage(pkg.name)) { 24 + return true 25 + } 26 + 27 + // Has bin but no entry points 28 + const hasBin = 29 + pkg.bin !== undefined && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0) 30 + const hasEntryPoint = !!pkg.main || !!pkg.module || !!pkg.exports 31 + 32 + return hasBin && !hasEntryPoint 33 + } 34 + 35 + /** 36 + * Check if a package uses the create-* naming convention. 37 + */ 38 + export function isCreatePackage(packageName: string): boolean { 39 + const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName 40 + return baseName?.startsWith('create-') || packageName.includes('/create-') || false 41 + }
+1 -2
test/unit/app/utils/run-command.spec.ts
··· 3 3 getExecutableInfo, 4 4 getRunCommand, 5 5 getRunCommandParts, 6 - isBinaryOnlyPackage, 7 - isCreatePackage, 8 6 } from '../../../../app/utils/run-command' 7 + import { isBinaryOnlyPackage, isCreatePackage } from '../../../../shared/utils/binary-detection' 9 8 import type { JsrPackageInfo } from '../../../../shared/types/jsr' 10 9 11 10 describe('executable detection and run commands', () => {