diff --git a/app/components/Compare/ComparisonGrid.vue b/app/components/Compare/ComparisonGrid.vue index 437e53fba..a69ae50ee 100644 --- a/app/components/Compare/ComparisonGrid.vue +++ b/app/components/Compare/ComparisonGrid.vue @@ -1,10 +1,100 @@ @@ -68,6 +292,16 @@ defineProps<{ text-align: center; } +/* "No dep" column styling */ +.comparison-header > .comparison-cell-header.comparison-cell-special { + background: linear-gradient( + 135deg, + var(--color-bg-subtle) 0%, + color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-subtle)) 100% + ); + border-bottom-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border)); +} + /* First header cell rounded top-start */ .comparison-header > .comparison-cell-header:first-of-type { border-start-start-radius: 0.5rem; diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue index 19d95afa6..ea5e7e377 100644 --- a/app/components/Compare/PackageSelector.vue +++ b/app/components/Compare/PackageSelector.vue @@ -1,4 +1,6 @@ + + diff --git a/app/composables/useCompareReplacements.ts b/app/composables/useCompareReplacements.ts new file mode 100644 index 000000000..6bb5a8d03 --- /dev/null +++ b/app/composables/useCompareReplacements.ts @@ -0,0 +1,107 @@ +import type { ModuleReplacement } from 'module-replacements' + +export interface ReplacementSuggestion { + forPackage: string + replacement: ModuleReplacement +} + +/** + * Replacement types that suggest "no dependency" (can be replaced with native code or inline). + */ +const NO_DEP_REPLACEMENT_TYPES = ['native', 'simple'] as const + +/** + * Replacement types that are informational only. + * These suggest alternative packages exist but don't fit the "no dependency" pattern. + */ +const INFO_REPLACEMENT_TYPES = ['documented'] as const + +/** + * Composable for fetching module replacement suggestions for packages in comparison. + * Returns replacements split into "no dep" (actionable) and informational categories. + */ +export function useCompareReplacements(packageNames: MaybeRefOrGetter) { + const packages = computed(() => toValue(packageNames)) + + // Cache replacement data by package name + const replacements = shallowRef(new Map()) + const loading = shallowRef(false) + + // Fetch replacements for all packages + async function fetchReplacements(names: string[]) { + if (names.length === 0) return + + // Filter out packages we've already checked + const namesToCheck = names.filter(name => !replacements.value.has(name)) + if (namesToCheck.length === 0) return + + loading.value = true + + try { + const results = await Promise.all( + namesToCheck.map(async name => { + try { + const replacement = await $fetch(`/api/replacements/${name}`) + return { name, replacement } + } catch { + return { name, replacement: null } + } + }), + ) + + const newReplacements = new Map(replacements.value) + for (const { name, replacement } of results) { + newReplacements.set(name, replacement) + } + replacements.value = newReplacements + } finally { + loading.value = false + } + } + + // Watch for package changes and fetch replacements + if (import.meta.client) { + watch( + packages, + newPackages => { + fetchReplacements(newPackages) + }, + { immediate: true }, + ) + } + + // Build suggestions from replacements + const allSuggestions = computed(() => { + const result: ReplacementSuggestion[] = [] + + for (const pkg of packages.value) { + const replacement = replacements.value.get(pkg) + if (!replacement) continue + + result.push({ forPackage: pkg, replacement }) + } + + return result + }) + + // Suggestions that prompt adding the "no dep" column (native, simple) + const noDepSuggestions = computed(() => + allSuggestions.value.filter(s => + (NO_DEP_REPLACEMENT_TYPES as readonly string[]).includes(s.replacement.type), + ), + ) + + // Informational suggestions that don't prompt "no dep" (documented) + const infoSuggestions = computed(() => + allSuggestions.value.filter(s => + (INFO_REPLACEMENT_TYPES as readonly string[]).includes(s.replacement.type), + ), + ) + + return { + replacements: readonly(replacements), + noDepSuggestions: readonly(noDepSuggestions), + infoSuggestions: readonly(infoSuggestions), + loading: readonly(loading), + } +} diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 690bff4da..e371696f1 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -11,6 +11,20 @@ import { isBinaryOnlyPackage } from '#shared/utils/binary-detection' import { formatBytes } from '~/utils/formatters' import { getDependencyCount } from '~/utils/npm/dependency-count' +/** Special identifier for the "What Would James Do?" comparison column */ +export const NO_DEPENDENCY_ID = '__no_dependency__' + +/** + * Special display values for the "no dependency" column. + * These are explicit markers that get special rendering treatment. + */ +export const NoDependencyDisplay = { + /** Display as "–" (en-dash) */ + DASH: '__display_dash__', + /** Display as "Up to you!" with good status */ + UP_TO_YOU: '__display_up_to_you__', +} as const + export interface PackageComparisonData { package: ComparisonPackage downloads?: number @@ -44,6 +58,8 @@ export interface PackageComparisonData { } /** Whether this is a binary-only package (CLI without library entry points) */ isBinaryOnly?: boolean + /** Marks this as the "no dependency" column for special display */ + isNoDependency?: boolean } /** @@ -76,8 +92,15 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return } - // Only fetch packages not already cached - const namesToFetch = names.filter(name => !cache.value.has(name)) + // Handle "no dependency" column - add to cache immediately + if (names.includes(NO_DEPENDENCY_ID) && !cache.value.has(NO_DEPENDENCY_ID)) { + const newCache = new Map(cache.value) + newCache.set(NO_DEPENDENCY_ID, createNoDependencyData()) + cache.value = newCache + } + + // Only fetch packages not already cached (excluding "no dep" which has no remote data) + const namesToFetch = names.filter(name => name !== NO_DEPENDENCY_ID && !cache.value.has(name)) if (namesToFetch.length === 0) { status.value = 'success' @@ -255,14 +278,69 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { } } +/** + * Creates mock data for the "What Would James Do?" comparison column. + * This represents the baseline of having no dependency at all. + * + * Uses explicit display markers (NoDependencyDisplay) instead of undefined + * to clearly indicate intentional special values vs missing data. + */ +function createNoDependencyData(): PackageComparisonData { + return { + package: { + name: '(No Dependency)', + version: '', + description: 'No dependency at all! @43081j approved.', + }, + isNoDependency: true, + downloads: undefined, + packageSize: 0, + directDeps: 0, + installSize: { + selfSize: 0, + totalSize: 0, + dependencyCount: 0, + }, + analysis: undefined, + vulnerabilities: undefined, + metadata: { + license: NoDependencyDisplay.DASH, + lastUpdated: NoDependencyDisplay.UP_TO_YOU, + engines: undefined, + deprecated: undefined, + }, + } +} + +/** + * Converts a special display marker to its FacetValue representation. + */ +function resolveNoDependencyDisplay( + marker: string, +): { display: string; status: FacetValue['status'] } | null { + switch (marker) { + case NoDependencyDisplay.DASH: + return { display: '–', status: 'neutral' } + case NoDependencyDisplay.UP_TO_YOU: + return { display: 'Up to you!', status: 'good' } + default: + return null + } +} + function computeFacetValue( facet: ComparisonFacet, data: PackageComparisonData, t: (key: string, params?: Record) => string, ): FacetValue | null { + const { isNoDependency } = data + switch (facet) { case 'downloads': { - if (data.downloads === undefined) return null + if (data.downloads === undefined) { + if (isNoDependency) return { raw: 0, display: '–', status: 'neutral' } + return null + } return { raw: data.downloads, display: formatCompactNumber(data.downloads), @@ -286,7 +364,10 @@ function computeFacetValue( } } case 'moduleFormat': { - if (!data.analysis) return null + if (!data.analysis) { + if (isNoDependency) return { raw: 'up-to-you', display: 'Up to you!', status: 'good' } + return null + } const format = data.analysis.moduleFormat return { raw: format, @@ -303,7 +384,10 @@ function computeFacetValue( tooltip: t('compare.facets.binary_only_tooltip'), } } - if (!data.analysis) return null + if (!data.analysis) { + if (isNoDependency) return { raw: 'up-to-you', display: 'Up to you!', status: 'good' } + return null + } const types = data.analysis.types return { raw: types.kind, @@ -319,7 +403,12 @@ function computeFacetValue( case 'engines': { const engines = data.metadata?.engines if (!engines?.node) { - return { raw: null, display: t('compare.facets.values.any'), status: 'neutral' } + if (isNoDependency) return { raw: 'up-to-you', display: 'Up to you!', status: 'good' } + return { + raw: null, + display: t('compare.facets.values.any'), + status: 'neutral', + } } return { raw: engines.node, @@ -328,7 +417,10 @@ function computeFacetValue( } } case 'vulnerabilities': { - if (!data.vulnerabilities) return null + if (!data.vulnerabilities) { + if (isNoDependency) return { raw: 'up-to-you', display: 'Up to you!', status: 'good' } + return null + } const count = data.vulnerabilities.count const sev = data.vulnerabilities.severity return { @@ -345,19 +437,29 @@ function computeFacetValue( } } case 'lastUpdated': { - if (!data.metadata?.lastUpdated) return null - const date = new Date(data.metadata.lastUpdated) + const lastUpdated = data.metadata?.lastUpdated + const resolved = lastUpdated ? resolveNoDependencyDisplay(lastUpdated) : null + if (resolved) return { raw: 0, ...resolved } + if (!lastUpdated) return null + const date = new Date(lastUpdated) return { raw: date.getTime(), - display: data.metadata.lastUpdated, + display: lastUpdated, status: isStale(date) ? 'warning' : 'neutral', type: 'date', } } case 'license': { const license = data.metadata?.license + const resolved = license ? resolveNoDependencyDisplay(license) : null + if (resolved) return { raw: null, ...resolved } if (!license) { - return { raw: null, display: t('compare.facets.values.unknown'), status: 'warning' } + if (isNoDependency) return { raw: null, display: '–', status: 'neutral' } + return { + raw: null, + display: t('compare.facets.values.unknown'), + status: 'warning', + } } return { raw: license, diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 0ac2a2e78..2a9bf2d10 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -1,4 +1,5 @@