Skip to content

Commit d9f66a4

Browse files
gusa4grrdanielroeautofix-ci[bot]
authored andcommitted
feat(ui): improve compare types in FacetRow for CLI package (npmx-dev#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>
1 parent e21f519 commit d9f66a4

File tree

8 files changed

+96
-64
lines changed

8 files changed

+96
-64
lines changed

app/components/compare/FacetRow.vue

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ function getStatusClass(status?: FacetValue['status']): string {
4848
return 'text-amber-400'
4949
case 'bad':
5050
return 'text-red-400'
51+
case 'muted':
52+
return 'text-fg-subtle'
5153
default:
5254
return 'text-fg'
5355
}
@@ -62,16 +64,11 @@ function isCellLoading(index: number): boolean {
6264
<template>
6365
<div class="contents">
6466
<!-- 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">
6968
<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>
7572
</div>
7673

7774
<!-- Value cells -->
@@ -103,7 +100,18 @@ function isCellLoading(index: number): boolean {
103100

104101
<!-- Value display -->
105102
<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>
106113
<span
114+
v-else
107115
class="relative font-mono text-sm text-center tabular-nums"
108116
:class="getStatusClass(value.status)"
109117
>

app/composables/usePackageComparison.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { FacetValue, ComparisonFacet, ComparisonPackage } from '#shared/types'
1+
import type { FacetValue, ComparisonFacet, ComparisonPackage, Packument } from '#shared/types'
22
import { encodePackageName } from '#shared/utils/npm'
33
import type { PackageAnalysisResponse } from './usePackageAnalysis'
4+
import { isBinaryOnlyPackage } from '#shared/utils/binary-detection'
45

56
export interface PackageComparisonData {
67
package: ComparisonPackage
@@ -24,13 +25,16 @@ export interface PackageComparisonData {
2425
engines?: { node?: string; npm?: string }
2526
deprecated?: string
2627
}
28+
/** Whether this is a binary-only package (CLI without library entry points) */
29+
isBinaryOnly?: boolean
2730
}
2831

2932
/**
3033
* Composable for fetching and comparing multiple packages.
3134
*
3235
*/
3336
export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
37+
const { t } = useI18n()
3438
const packages = computed(() => toValue(packageNames))
3539

3640
// Cache of fetched data by package name (source of truth)
@@ -75,13 +79,9 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
7579
namesToFetch.map(async (name): Promise<PackageComparisonData | null> => {
7680
try {
7781
// 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+
)
8585

8686
const latestVersion = pkgData['dist-tags']?.latest
8787
if (!latestVersion) return null
@@ -100,6 +100,15 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
100100
const versionData = pkgData.versions[latestVersion]
101101
const packageSize = versionData?.dist?.unpackedSize
102102

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+
103112
// Count vulnerabilities by severity
104113
const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 }
105114
const vulnList = vulns?.vulnerabilities ?? []
@@ -128,6 +137,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
128137
engines: analysis?.engines,
129138
deprecated: versionData?.deprecated,
130139
},
140+
isBinaryOnly: isBinary,
131141
}
132142
} catch {
133143
return null
@@ -196,7 +206,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
196206

197207
return packagesData.value.map(pkg => {
198208
if (!pkg) return null
199-
return computeFacetValue(facet, pkg)
209+
return computeFacetValue(facet, pkg, t)
200210
})
201211
}
202212

@@ -223,7 +233,11 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
223233
}
224234
}
225235

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 {
227241
switch (facet) {
228242
case 'downloads':
229243
if (data.downloads === undefined) return null
@@ -259,6 +273,14 @@ function computeFacetValue(facet: ComparisonFacet, data: PackageComparisonData):
259273
}
260274

261275
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+
}
262284
if (!data.analysis) return null
263285
const types = data.analysis.types
264286
return {

app/utils/run-command.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,6 @@ import type { JsrPackageInfo } from '#shared/types/jsr'
22
import { getPackageSpecifier, packageManagers } from './install-command'
33
import type { PackageManagerId } from './install-command'
44

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-
475
/**
486
* Information about executable commands provided by a package.
497
*/

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,7 @@
852852
"deselect_all": "Deselect all facets",
853853
"select_category": "Select all {category} facets",
854854
"deselect_category": "Deselect all {category} facets",
855+
"binary_only_tooltip": "This package exposes binaries and no exports",
855856
"categories": {
856857
"performance": "Performance",
857858
"health": "Health",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,7 @@
852852
"deselect_all": "Deselect all facets",
853853
"select_category": "Select all {category} facets",
854854
"deselect_category": "Deselect all {category} facets",
855+
"binary_only_tooltip": "This package exposes binaries and no exports",
855856
"categories": {
856857
"performance": "Performance",
857858
"health": "Health",

shared/types/comparison.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,11 @@ export interface FacetValue<T = unknown> {
124124
/** Formatted display string (or ISO date string if type is 'date') */
125125
display: string
126126
/** Optional status indicator */
127-
status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral'
127+
status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral' | 'muted'
128128
/** Value type for special rendering (e.g., dates use DateTime component) */
129129
type?: 'date'
130+
/** Optional tooltip text to explain the value */
131+
tooltip?: string
130132
}
131133

132134
/** Package data for comparison */

shared/utils/binary-detection.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
}

test/unit/app/utils/run-command.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import {
33
getExecutableInfo,
44
getRunCommand,
55
getRunCommandParts,
6-
isBinaryOnlyPackage,
7-
isCreatePackage,
86
} from '../../../../app/utils/run-command'
7+
import { isBinaryOnlyPackage, isCreatePackage } from '../../../../shared/utils/binary-detection'
98
import type { JsrPackageInfo } from '../../../../shared/types/jsr'
109

1110
describe('executable detection and run commands', () => {

0 commit comments

Comments
 (0)