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

feat(ui): add e18e module replacement recommendations to `/compare` page (#801)

authored by philippeserhal.com and committed by

GitHub 4cec3086 a8bd2f24

+1110 -100
+92 -15
app/components/Compare/ComparisonGrid.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 3 - /** Number of columns (2-4) */ 4 - columns: number 5 - /** Column headers (package names or version numbers) */ 6 - headers: string[] 2 + import type { ModuleReplacement } from 'module-replacements' 3 + 4 + export interface ComparisonGridColumn { 5 + /** Display text (e.g. "lodash@4.17.21") */ 6 + header: string 7 + /** Module replacement data for this package (if available) */ 8 + replacement?: ModuleReplacement | null 9 + } 10 + 11 + const props = defineProps<{ 12 + /** Column definitions for each package being compared */ 13 + columns: ComparisonGridColumn[] 14 + /** Whether to show the "no dependency" baseline as the last column */ 15 + showNoDependency?: boolean 7 16 }>() 17 + 18 + /** Total column count including the optional no-dep column */ 19 + const totalColumns = computed(() => props.columns.length + (props.showNoDependency ? 1 : 0)) 20 + 21 + /** Compute plain-text tooltip for a replacement column */ 22 + function getReplacementTooltip(col: ComparisonGridColumn): string { 23 + if (!col.replacement) return '' 24 + 25 + return [$t('package.replacement.title'), $t('package.replacement.learn_more_above')].join(' ') 26 + } 8 27 </script> 9 28 10 29 <template> 11 30 <div class="overflow-x-auto"> 12 31 <div 13 32 class="comparison-grid" 14 - :class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]" 15 - :style="{ '--columns': columns }" 33 + :class="[totalColumns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${totalColumns}`]" 34 + :style="{ '--columns': totalColumns }" 16 35 > 17 36 <!-- Header row --> 18 37 <div class="comparison-header"> 19 38 <div class="comparison-label" /> 39 + 40 + <!-- Package columns --> 20 41 <div 21 - v-for="(header, index) in headers" 22 - :key="index" 42 + v-for="col in columns" 43 + :key="col.header" 23 44 class="comparison-cell comparison-cell-header" 24 45 > 25 - <NuxtLink 26 - :to="`/package/${header}`" 27 - class="link-subtle font-mono text-sm font-medium text-fg truncate" 28 - :title="header" 46 + <span class="inline-flex items-center gap-1.5 truncate"> 47 + <NuxtLink 48 + :to="`/package/${col.header}`" 49 + class="link-subtle font-mono text-sm font-medium text-fg truncate" 50 + :title="col.header" 51 + > 52 + {{ col.header }} 53 + </NuxtLink> 54 + <TooltipApp v-if="col.replacement" :text="getReplacementTooltip(col)" position="bottom"> 55 + <span 56 + class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help" 57 + role="img" 58 + :aria-label="$t('package.replacement.title')" 59 + /> 60 + </TooltipApp> 61 + </span> 62 + </div> 63 + 64 + <!-- "No dep" column (always last) --> 65 + <div 66 + v-if="showNoDependency" 67 + class="comparison-cell comparison-cell-header comparison-cell-nodep" 68 + > 69 + <span 70 + class="inline-flex items-center gap-1.5 text-sm font-medium text-accent italic truncate" 29 71 > 30 - {{ header }} 31 - </NuxtLink> 72 + {{ $t('compare.no_dependency.label') }} 73 + <TooltipApp interactive position="bottom"> 74 + <span 75 + class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help" 76 + role="img" 77 + :aria-label="$t('compare.no_dependency.tooltip_title')" 78 + /> 79 + <template #content> 80 + <p class="text-sm font-medium text-fg mb-1"> 81 + {{ $t('compare.no_dependency.tooltip_title') }} 82 + </p> 83 + <p class="text-xs text-fg-muted"> 84 + <i18n-t keypath="compare.no_dependency.tooltip_description" tag="span"> 85 + <template #link> 86 + <a 87 + href="https://e18e.dev/docs/replacements/" 88 + target="_blank" 89 + rel="noopener noreferrer" 90 + class="text-accent hover:underline" 91 + >{{ $t('compare.no_dependency.e18e_community') }}</a 92 + > 93 + </template> 94 + </i18n-t> 95 + </p> 96 + </template> 97 + </TooltipApp> 98 + </span> 32 99 </div> 33 100 </div> 34 101 ··· 70 137 background: var(--color-bg-subtle); 71 138 border-bottom: 1px solid var(--color-border); 72 139 text-align: center; 140 + } 141 + 142 + /* "No dep" column styling */ 143 + .comparison-header > .comparison-cell-header.comparison-cell-nodep { 144 + background: linear-gradient( 145 + 135deg, 146 + var(--color-bg-subtle) 0%, 147 + color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-subtle)) 100% 148 + ); 149 + border-bottom-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border)); 73 150 } 74 151 75 152 /* First header cell rounded top-start */
+76 -6
app/components/Compare/PackageSelector.vue
··· 1 1 <script setup lang="ts"> 2 + import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison' 3 + 2 4 const packages = defineModel<string[]>({ required: true }) 3 5 4 6 const props = defineProps<{ ··· 17 19 18 20 const isSearching = computed(() => status.value === 'pending') 19 21 22 + // Trigger strings for "What Would James Do?" typeahead Easter egg 23 + // Intentionally not localized 24 + const EASTER_EGG_TRIGGERS = new Set([ 25 + 'no dep', 26 + 'none', 27 + 'vanilla', 28 + 'diy', 29 + 'zero', 30 + 'nothing', 31 + '0', 32 + "don't", 33 + 'native', 34 + 'use the platform', 35 + ]) 36 + 37 + // Check if "no dependency" option should show in typeahead 38 + const showNoDependencyOption = computed(() => { 39 + if (packages.value.includes(NO_DEPENDENCY_ID)) return false 40 + const input = inputValue.value.toLowerCase().trim() 41 + if (!input) return false 42 + return EASTER_EGG_TRIGGERS.has(input) 43 + }) 44 + 20 45 // Filter out already selected packages 21 46 const filteredResults = computed(() => { 22 47 if (!searchData.value?.objects) return [] ··· 32 57 if (packages.value.length >= maxPackages.value) return 33 58 if (packages.value.includes(name)) return 34 59 35 - packages.value = [...packages.value, name] 60 + // Keep NO_DEPENDENCY_ID always last 61 + if (name === NO_DEPENDENCY_ID) { 62 + packages.value = [...packages.value, name] 63 + } else if (packages.value.includes(NO_DEPENDENCY_ID)) { 64 + // Insert before the no-dep entry 65 + const withoutNoDep = packages.value.filter(p => p !== NO_DEPENDENCY_ID) 66 + packages.value = [...withoutNoDep, name, NO_DEPENDENCY_ID] 67 + } else { 68 + packages.value = [...packages.value, name] 69 + } 36 70 inputValue.value = '' 37 71 } 38 72 ··· 63 97 :key="pkg" 64 98 class="inline-flex items-center gap-2 px-3 py-1.5 bg-bg-subtle border border-border rounded-md" 65 99 > 100 + <!-- No dependency display --> 101 + <template v-if="pkg === NO_DEPENDENCY_ID"> 102 + <span class="text-sm text-accent italic flex items-center gap-1.5"> 103 + <span class="i-carbon:clean w-3.5 h-3.5" aria-hidden="true" /> 104 + {{ $t('compare.no_dependency.label') }} 105 + </span> 106 + </template> 66 107 <NuxtLink 108 + v-else 67 109 :to="`/package/${pkg}`" 68 110 class="font-mono text-sm text-fg hover:text-accent transition-colors" 69 111 > ··· 71 113 </NuxtLink> 72 114 <button 73 115 type="button" 74 - class="text-fg-subtle hover:text-fg transition-colors focus-visible:outline-accent/70 rounded" 75 - :aria-label="$t('compare.selector.remove_package', { package: pkg })" 116 + class="text-fg-subtle hover:text-fg transition-colors rounded" 117 + :aria-label=" 118 + $t('compare.selector.remove_package', { 119 + package: pkg === NO_DEPENDENCY_ID ? $t('compare.no_dependency.label') : pkg, 120 + }) 121 + " 76 122 @click="removePackage(pkg)" 77 123 > 78 124 <span class="i-carbon:close flex items-center w-3.5 h-3.5" aria-hidden="true" /> ··· 118 164 leave-to-class="opacity-0" 119 165 > 120 166 <div 121 - v-if="isInputFocused && (filteredResults.length > 0 || isSearching)" 167 + v-if=" 168 + isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption) 169 + " 122 170 class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto" 123 171 > 172 + <!-- No dependency option (easter egg with James) --> 173 + <button 174 + v-if="showNoDependencyOption" 175 + type="button" 176 + class="w-full text-start px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted border-b border-border/50" 177 + :aria-label="$t('compare.no_dependency.add_column')" 178 + @click="addPackage(NO_DEPENDENCY_ID)" 179 + > 180 + <div class="text-sm text-accent italic flex items-center gap-2"> 181 + <span class="i-carbon:clean w-4 h-4" aria-hidden="true" /> 182 + {{ $t('compare.no_dependency.typeahead_title') }} 183 + </div> 184 + <div class="text-xs text-fg-muted truncate mt-0.5"> 185 + {{ $t('compare.no_dependency.typeahead_description') }} 186 + </div> 187 + </button> 188 + 124 189 <div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted"> 125 190 {{ $t('compare.selector.searching') }} 126 191 </div> ··· 128 193 v-for="result in filteredResults" 129 194 :key="result.name" 130 195 type="button" 131 - class="w-full text-left px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted" 196 + class="w-full text-start px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted" 132 197 @click="addPackage(result.name)" 133 198 > 134 199 <div class="font-mono text-sm text-fg">{{ result.name }}</div> ··· 142 207 143 208 <!-- Hint --> 144 209 <p class="text-xs text-fg-subtle"> 145 - {{ $t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }} 210 + {{ 211 + $t('compare.selector.packages_selected', { 212 + count: packages.length, 213 + max: maxPackages, 214 + }) 215 + }} 146 216 <span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span> 147 217 </p> 148 218 </div>
+89
app/components/Compare/ReplacementSuggestion.vue
··· 1 + <script setup lang="ts"> 2 + import type { ModuleReplacement } from 'module-replacements' 3 + 4 + const props = defineProps<{ 5 + packageName: string 6 + replacement: ModuleReplacement 7 + /** Whether this suggestion should show the "no dep" action (native/simple) or just info (documented) */ 8 + variant: 'nodep' | 'info' 9 + /** Whether to show the action button (defaults to true) */ 10 + showAction?: boolean 11 + }>() 12 + 13 + const emit = defineEmits<{ 14 + addNoDep: [] 15 + }>() 16 + 17 + const docUrl = computed(() => { 18 + if (props.replacement.type !== 'documented' || !props.replacement.docPath) return null 19 + // TODO(serhalp): Once the e18e docs site is complete, link there instead 20 + return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${props.replacement.docPath}.md` 21 + }) 22 + </script> 23 + 24 + <template> 25 + <div 26 + class="flex items-start gap-2 px-3 py-2 rounded-lg text-sm" 27 + :class=" 28 + variant === 'nodep' 29 + ? 'bg-amber-500/10 border border-amber-600/30 text-amber-700 dark:text-amber-400' 30 + : 'bg-blue-500/10 border border-blue-600/30 text-blue-700 dark:text-blue-400' 31 + " 32 + > 33 + <span 34 + class="w-4 h-4 flex-shrink-0 mt-0.5" 35 + :class="variant === 'nodep' ? 'i-carbon:idea' : 'i-carbon:information'" 36 + /> 37 + <div class="min-w-0 flex-1"> 38 + <p class="font-medium">{{ packageName }}: {{ $t('package.replacement.title') }}</p> 39 + <p class="text-xs mt-0.5 opacity-80"> 40 + <template v-if="replacement.type === 'native'"> 41 + {{ 42 + $t('package.replacement.native', { 43 + replacement: replacement.replacement, 44 + nodeVersion: replacement.nodeVersion, 45 + }) 46 + }} 47 + </template> 48 + <template v-else-if="replacement.type === 'simple'"> 49 + {{ 50 + $t('package.replacement.simple', { 51 + replacement: replacement.replacement, 52 + community: $t('package.replacement.community'), 53 + }) 54 + }} 55 + </template> 56 + <template v-else-if="replacement.type === 'documented'"> 57 + {{ 58 + $t('package.replacement.documented', { 59 + community: $t('package.replacement.community'), 60 + }) 61 + }} 62 + </template> 63 + </p> 64 + </div> 65 + 66 + <!-- No dependency action button --> 67 + <button 68 + v-if="variant === 'nodep' && showAction !== false" 69 + type="button" 70 + class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-amber-500/20 hover:bg-amber-500/30 rounded transition-colors" 71 + :aria-label="$t('compare.no_dependency.add_column')" 72 + @click="emit('addNoDep')" 73 + > 74 + {{ $t('package.replacement.consider_no_dep') }} 75 + </button> 76 + 77 + <!-- Info link --> 78 + <a 79 + v-else-if="docUrl" 80 + :href="docUrl" 81 + target="_blank" 82 + rel="noopener noreferrer" 83 + class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-blue-500/20 hover:bg-blue-500/30 rounded transition-colors inline-flex items-center gap-1" 84 + > 85 + {{ $t('package.replacement.learn_more') }} 86 + <span class="i-carbon:launch w-3 h-3" /> 87 + </a> 88 + </div> 89 + </template>
+10 -2
app/components/Package/Replacement.vue
··· 5 5 replacement: ModuleReplacement 6 6 }>() 7 7 8 - const message = computed<[string, { replacement?: string; nodeVersion?: string }]>(() => { 8 + const message = computed< 9 + [string, { replacement?: string; nodeVersion?: string; community?: string }] 10 + >(() => { 9 11 switch (props.replacement.type) { 10 12 case 'native': 11 13 return [ ··· 20 22 'package.replacement.simple', 21 23 { 22 24 replacement: props.replacement.replacement, 25 + community: $t('package.replacement.community'), 23 26 }, 24 27 ] 25 28 case 'documented': 26 - return ['package.replacement.documented', {}] 29 + return [ 30 + 'package.replacement.documented', 31 + { 32 + community: $t('package.replacement.community'), 33 + }, 34 + ] 27 35 case 'none': 28 36 return ['package.replacement.none', {}] 29 37 }
+34 -6
app/components/Tooltip/App.vue
··· 1 1 <script setup lang="ts"> 2 2 const props = defineProps<{ 3 - /** Tooltip text */ 4 - text: string 3 + /** Tooltip text (optional when using content slot) */ 4 + text?: string 5 5 /** Position: 'top' | 'bottom' | 'left' | 'right' */ 6 6 position?: 'top' | 'bottom' | 'left' | 'right' 7 + /** Enable interactive tooltip (pointer events + hide delay for clickable content) */ 8 + interactive?: boolean 7 9 }>() 8 10 9 11 const isVisible = shallowRef(false) 10 12 const tooltipId = useId() 13 + const hideTimeout = shallowRef<ReturnType<typeof setTimeout> | null>(null) 11 14 12 15 function show() { 16 + if (hideTimeout.value) { 17 + clearTimeout(hideTimeout.value) 18 + hideTimeout.value = null 19 + } 13 20 isVisible.value = true 14 21 } 15 22 16 23 function hide() { 17 - isVisible.value = false 24 + if (props.interactive) { 25 + // Delay hide so cursor can travel from trigger to tooltip 26 + hideTimeout.value = setTimeout(() => { 27 + isVisible.value = false 28 + }, 150) 29 + } else { 30 + isVisible.value = false 31 + } 18 32 } 33 + 34 + const tooltipAttrs = computed(() => { 35 + const attrs: Record<string, unknown> = { role: 'tooltip', id: tooltipId } 36 + if (props.interactive) { 37 + attrs.onMouseenter = show 38 + attrs.onMouseleave = hide 39 + } 40 + return attrs 41 + }) 19 42 </script> 20 43 21 44 <template> ··· 23 46 :text 24 47 :isVisible 25 48 :position 26 - :tooltip-attr="{ role: 'tooltip', id: tooltipId }" 49 + :interactive 50 + :tooltip-attr="tooltipAttrs" 27 51 @mouseenter="show" 28 52 @mouseleave="hide" 29 53 @focusin="show" 30 54 @focusout="hide" 31 55 :aria-describedby="isVisible ? tooltipId : undefined" 32 - ><slot 33 - /></TooltipBase> 56 + > 57 + <slot /> 58 + <template v-if="$slots.content" #content> 59 + <slot name="content" /> 60 + </template> 61 + </TooltipBase> 34 62 </template>
+7 -4
app/components/Tooltip/Base.vue
··· 4 4 import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' 5 5 6 6 const props = defineProps<{ 7 - /** Tooltip text */ 8 - text: string 7 + /** Tooltip text (optional when using content slot) */ 8 + text?: string 9 9 /** Position: 'top' | 'bottom' | 'left' | 'right' */ 10 10 position?: 'top' | 'bottom' | 'left' | 'right' 11 11 /** is tooltip visible */ 12 12 isVisible: boolean 13 + /** Allow pointer events on tooltip (for interactive content like links) */ 14 + interactive?: boolean 13 15 /** attributes for tooltip element */ 14 16 tooltipAttr?: HTMLAttributes 15 17 }>() ··· 40 42 <div 41 43 v-if="props.isVisible" 42 44 ref="tooltipRef" 43 - class="px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-pre-line z-[100] pointer-events-none" 45 + class="px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-pre-line break-words max-w-xs z-[100]" 46 + :class="{ 'pointer-events-none': !interactive }" 44 47 :style="floatingStyles" 45 48 v-bind="tooltipAttr" 46 49 > 47 - {{ text }} 50 + <slot name="content">{{ text }}</slot> 48 51 </div> 49 52 </Transition> 50 53 </Teleport>
+107
app/composables/useCompareReplacements.ts
··· 1 + import type { ModuleReplacement } from 'module-replacements' 2 + 3 + export interface ReplacementSuggestion { 4 + forPackage: string 5 + replacement: ModuleReplacement 6 + } 7 + 8 + /** 9 + * Replacement types that suggest "no dependency" (can be replaced with native code or inline). 10 + */ 11 + const NO_DEP_REPLACEMENT_TYPES = ['native', 'simple'] as const 12 + 13 + /** 14 + * Replacement types that are informational only. 15 + * These suggest alternative packages exist but don't fit the "no dependency" pattern. 16 + */ 17 + const INFO_REPLACEMENT_TYPES = ['documented'] as const 18 + 19 + /** 20 + * Composable for fetching module replacement suggestions for packages in comparison. 21 + * Returns replacements split into "no dep" (actionable) and informational categories. 22 + */ 23 + export function useCompareReplacements(packageNames: MaybeRefOrGetter<string[]>) { 24 + const packages = computed(() => toValue(packageNames)) 25 + 26 + // Cache replacement data by package name 27 + const replacements = shallowRef(new Map<string, ModuleReplacement | null>()) 28 + const loading = shallowRef(false) 29 + 30 + // Fetch replacements for all packages 31 + async function fetchReplacements(names: string[]) { 32 + if (names.length === 0) return 33 + 34 + // Filter out packages we've already checked 35 + const namesToCheck = names.filter(name => !replacements.value.has(name)) 36 + if (namesToCheck.length === 0) return 37 + 38 + loading.value = true 39 + 40 + try { 41 + const results = await Promise.all( 42 + namesToCheck.map(async name => { 43 + try { 44 + const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`) 45 + return { name, replacement } 46 + } catch { 47 + return { name, replacement: null } 48 + } 49 + }), 50 + ) 51 + 52 + const newReplacements = new Map(replacements.value) 53 + for (const { name, replacement } of results) { 54 + newReplacements.set(name, replacement) 55 + } 56 + replacements.value = newReplacements 57 + } finally { 58 + loading.value = false 59 + } 60 + } 61 + 62 + // Watch for package changes and fetch replacements 63 + if (import.meta.client) { 64 + watch( 65 + packages, 66 + newPackages => { 67 + fetchReplacements(newPackages) 68 + }, 69 + { immediate: true }, 70 + ) 71 + } 72 + 73 + // Build suggestions from replacements 74 + const allSuggestions = computed(() => { 75 + const result: ReplacementSuggestion[] = [] 76 + 77 + for (const pkg of packages.value) { 78 + const replacement = replacements.value.get(pkg) 79 + if (!replacement) continue 80 + 81 + result.push({ forPackage: pkg, replacement }) 82 + } 83 + 84 + return result 85 + }) 86 + 87 + // Suggestions that prompt adding the "no dep" column (native, simple) 88 + const noDepSuggestions = computed(() => 89 + allSuggestions.value.filter(s => 90 + (NO_DEP_REPLACEMENT_TYPES as readonly string[]).includes(s.replacement.type), 91 + ), 92 + ) 93 + 94 + // Informational suggestions that don't prompt "no dep" (documented) 95 + const infoSuggestions = computed(() => 96 + allSuggestions.value.filter(s => 97 + (INFO_REPLACEMENT_TYPES as readonly string[]).includes(s.replacement.type), 98 + ), 99 + ) 100 + 101 + return { 102 + replacements: readonly(replacements), 103 + noDepSuggestions: readonly(noDepSuggestions), 104 + infoSuggestions: readonly(infoSuggestions), 105 + loading: readonly(loading), 106 + } 107 + }
+146 -20
app/composables/usePackageComparison.ts
··· 11 11 import { formatBytes } from '~/utils/formatters' 12 12 import { getDependencyCount } from '~/utils/npm/dependency-count' 13 13 14 + /** Special identifier for the "What Would James Do?" comparison column */ 15 + export const NO_DEPENDENCY_ID = '__no_dependency__' 16 + 17 + /** 18 + * Special display values for the "no dependency" column. 19 + * These are explicit markers that get special rendering treatment. 20 + */ 21 + export const NoDependencyDisplay = { 22 + /** Display as "–" (en-dash) */ 23 + DASH: '__display_dash__', 24 + /** Display as "Up to you!" with good status */ 25 + UP_TO_YOU: '__display_up_to_you__', 26 + } as const 27 + 14 28 export interface PackageComparisonData { 15 29 package: ComparisonPackage 16 30 downloads?: number ··· 44 58 } 45 59 /** Whether this is a binary-only package (CLI without library entry points) */ 46 60 isBinaryOnly?: boolean 61 + /** Marks this as the "no dependency" column for special display */ 62 + isNoDependency?: boolean 47 63 } 48 64 49 65 /** ··· 76 92 return 77 93 } 78 94 79 - // Only fetch packages not already cached 80 - const namesToFetch = names.filter(name => !cache.value.has(name)) 95 + // Handle "no dependency" column - add to cache immediately 96 + if (names.includes(NO_DEPENDENCY_ID) && !cache.value.has(NO_DEPENDENCY_ID)) { 97 + const newCache = new Map(cache.value) 98 + newCache.set(NO_DEPENDENCY_ID, createNoDependencyData()) 99 + cache.value = newCache 100 + } 101 + 102 + // Only fetch packages not already cached (excluding "no dep" which has no remote data) 103 + const namesToFetch = names.filter(name => name !== NO_DEPENDENCY_ID && !cache.value.has(name)) 81 104 82 105 if (namesToFetch.length === 0) { 83 106 status.value = 'success' ··· 108 131 $fetch<{ downloads: number }>( 109 132 `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, 110 133 ).catch(() => null), 111 - $fetch<PackageAnalysisResponse>(`/api/registry/analysis/${name}`).catch(() => null), 112 - $fetch<VulnerabilityTreeResult>(`/api/registry/vulnerabilities/${name}`).catch( 113 - () => null, 114 - ), 134 + $fetch<PackageAnalysisResponse>( 135 + `/api/registry/analysis/${encodePackageName(name)}`, 136 + ).catch(() => null), 137 + $fetch<VulnerabilityTreeResult>( 138 + `/api/registry/vulnerabilities/${encodePackageName(name)}`, 139 + ).catch(() => null), 115 140 ]) 116 141 117 142 const versionData = pkgData.versions[latestVersion] ··· 191 216 selfSize: number 192 217 totalSize: number 193 218 dependencyCount: number 194 - }>(`/api/registry/install-size/${name}`) 219 + }>(`/api/registry/install-size/${encodePackageName(name)}`) 195 220 196 221 // Update cache with install size 197 222 const existing = cache.value.get(name) ··· 258 283 } 259 284 } 260 285 286 + /** 287 + * Creates mock data for the "What Would James Do?" comparison column. 288 + * This represents the baseline of having no dependency at all. 289 + * 290 + * Uses explicit display markers (NoDependencyDisplay) instead of undefined 291 + * to clearly indicate intentional special values vs missing data. 292 + */ 293 + function createNoDependencyData(): PackageComparisonData { 294 + return { 295 + package: { 296 + name: NO_DEPENDENCY_ID, 297 + version: '', 298 + description: undefined, 299 + }, 300 + isNoDependency: true, 301 + downloads: undefined, 302 + packageSize: 0, 303 + directDeps: 0, 304 + installSize: { 305 + selfSize: 0, 306 + totalSize: 0, 307 + dependencyCount: 0, 308 + }, 309 + analysis: undefined, 310 + vulnerabilities: undefined, 311 + metadata: { 312 + license: NoDependencyDisplay.DASH, 313 + lastUpdated: NoDependencyDisplay.UP_TO_YOU, 314 + engines: undefined, 315 + deprecated: undefined, 316 + }, 317 + } 318 + } 319 + 320 + /** 321 + * Converts a special display marker to its FacetValue representation. 322 + */ 323 + function resolveNoDependencyDisplay( 324 + marker: string, 325 + t: (key: string) => string, 326 + ): { display: string; status: FacetValue['status'] } | null { 327 + switch (marker) { 328 + case NoDependencyDisplay.DASH: 329 + return { display: '–', status: 'neutral' } 330 + case NoDependencyDisplay.UP_TO_YOU: 331 + return { display: t('compare.facets.values.up_to_you'), status: 'good' } 332 + default: 333 + return null 334 + } 335 + } 336 + 261 337 function computeFacetValue( 262 338 facet: ComparisonFacet, 263 339 data: PackageComparisonData, 264 340 t: (key: string, params?: Record<string, unknown>) => string, 265 341 ): FacetValue | null { 342 + const { isNoDependency } = data 343 + 266 344 switch (facet) { 267 345 case 'downloads': { 268 - if (data.downloads === undefined) return null 346 + if (data.downloads === undefined) { 347 + if (isNoDependency) return { raw: 0, display: '–', status: 'neutral' } 348 + return null 349 + } 269 350 return { 270 351 raw: data.downloads, 271 352 display: formatCompactNumber(data.downloads), ··· 273 354 } 274 355 } 275 356 case 'packageSize': { 276 - if (!data.packageSize) return null 357 + // A size of zero is valid 358 + if (data.packageSize == null) return null 277 359 return { 278 360 raw: data.packageSize, 279 361 display: formatBytes(data.packageSize), ··· 281 363 } 282 364 } 283 365 case 'installSize': { 284 - if (!data.installSize) return null 366 + // A size of zero is valid 367 + if (data.installSize == null) return null 285 368 return { 286 369 raw: data.installSize.totalSize, 287 370 display: formatBytes(data.installSize.totalSize), ··· 289 372 } 290 373 } 291 374 case 'moduleFormat': { 292 - if (!data.analysis) return null 375 + if (!data.analysis) { 376 + if (isNoDependency) 377 + return { 378 + raw: 'up-to-you', 379 + display: t('compare.facets.values.up_to_you'), 380 + status: 'good', 381 + } 382 + return null 383 + } 293 384 const format = data.analysis.moduleFormat 294 385 return { 295 386 raw: format, ··· 306 397 tooltip: t('compare.facets.binary_only_tooltip'), 307 398 } 308 399 } 309 - if (!data.analysis) return null 400 + if (!data.analysis) { 401 + if (isNoDependency) 402 + return { 403 + raw: 'up-to-you', 404 + display: t('compare.facets.values.up_to_you'), 405 + status: 'good', 406 + } 407 + return null 408 + } 310 409 const types = data.analysis.types 311 410 return { 312 411 raw: types.kind, ··· 322 421 case 'engines': { 323 422 const engines = data.metadata?.engines 324 423 if (!engines?.node) { 325 - return { raw: null, display: t('compare.facets.values.any'), status: 'neutral' } 424 + if (isNoDependency) 425 + return { 426 + raw: 'up-to-you', 427 + display: t('compare.facets.values.up_to_you'), 428 + status: 'good', 429 + } 430 + return { 431 + raw: null, 432 + display: t('compare.facets.values.any'), 433 + status: 'neutral', 434 + } 326 435 } 327 436 return { 328 437 raw: engines.node, ··· 331 440 } 332 441 } 333 442 case 'vulnerabilities': { 334 - if (!data.vulnerabilities) return null 443 + if (!data.vulnerabilities) { 444 + if (isNoDependency) 445 + return { 446 + raw: 'up-to-you', 447 + display: t('compare.facets.values.up_to_you'), 448 + status: 'good', 449 + } 450 + return null 451 + } 335 452 const count = data.vulnerabilities.count 336 453 const sev = data.vulnerabilities.severity 337 454 return { ··· 348 465 } 349 466 } 350 467 case 'lastUpdated': { 351 - if (!data.metadata?.lastUpdated) return null 352 - const date = new Date(data.metadata.lastUpdated) 468 + const lastUpdated = data.metadata?.lastUpdated 469 + const resolved = lastUpdated ? resolveNoDependencyDisplay(lastUpdated, t) : null 470 + if (resolved) return { raw: 0, ...resolved } 471 + if (!lastUpdated) return null 472 + const date = new Date(lastUpdated) 353 473 return { 354 474 raw: date.getTime(), 355 - display: data.metadata.lastUpdated, 475 + display: lastUpdated, 356 476 status: isStale(date) ? 'warning' : 'neutral', 357 477 type: 'date', 358 478 } 359 479 } 360 480 case 'license': { 361 481 const license = data.metadata?.license 482 + const resolved = license ? resolveNoDependencyDisplay(license, t) : null 483 + if (resolved) return { raw: null, ...resolved } 362 484 if (!license) { 363 - return { raw: null, display: t('compare.facets.values.unknown'), status: 'warning' } 485 + if (isNoDependency) return { raw: null, display: '–', status: 'neutral' } 486 + return { 487 + raw: null, 488 + display: t('compare.facets.values.unknown'), 489 + status: 'warning', 490 + } 364 491 } 365 492 return { 366 493 raw: license, ··· 370 497 } 371 498 case 'dependencies': { 372 499 const depCount = data.directDeps 373 - if (depCount === null) return null 500 + if (depCount == null) return null 374 501 return { 375 502 raw: depCount, 376 503 display: String(depCount), ··· 387 514 status: isDeprecated ? 'bad' : 'good', 388 515 } 389 516 } 390 - // Coming soon facets 391 517 case 'totalDependencies': { 392 518 if (!data.installSize) return null 393 519 const totalDepCount = data.installSize.dependencyCount
+66 -9
app/pages/compare.vue
··· 1 1 <script setup lang="ts"> 2 + import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison' 2 3 import { useRouteQuery } from '@vueuse/router' 3 4 4 5 definePageMeta({ ··· 32 33 // Fetch comparison data 33 34 const { packagesData, status, getFacetValues, isFacetLoading, isColumnLoading } = 34 35 usePackageComparison(packages) 36 + 37 + // Fetch module replacement suggestions 38 + const { noDepSuggestions, infoSuggestions, replacements } = useCompareReplacements(packages) 39 + 40 + // Whether the "no dependency" baseline column is active 41 + const showNoDependency = computed(() => packages.value.includes(NO_DEPENDENCY_ID)) 42 + 43 + // Build column definitions for real packages only (no-dep is handled separately by the grid) 44 + const gridColumns = computed(() => 45 + packages.value 46 + .map((pkg, i) => ({ pkg, originalIndex: i })) 47 + .filter(({ pkg }) => pkg !== NO_DEPENDENCY_ID) 48 + .map(({ pkg, originalIndex }) => { 49 + const data = packagesData.value?.[originalIndex] 50 + const header = data 51 + ? data.package.version 52 + ? `${data.package.name}@${data.package.version}` 53 + : data.package.name 54 + : pkg 55 + return { 56 + header, 57 + replacement: replacements.value.get(pkg) ?? null, 58 + } 59 + }), 60 + ) 61 + 62 + // Whether we can add the no-dep column (not already added and have room) 63 + const canAddNoDep = computed( 64 + () => packages.value.length < 4 && !packages.value.includes(NO_DEPENDENCY_ID), 65 + ) 66 + 67 + // Add "no dependency" column to comparison 68 + function addNoDep() { 69 + if (packages.value.length >= 4) return 70 + if (packages.value.includes(NO_DEPENDENCY_ID)) return 71 + packages.value = [...packages.value, NO_DEPENDENCY_ID] 72 + } 35 73 36 74 // Get loading state for each column 37 75 const columnLoading = computed(() => packages.value.map((_, i) => isColumnLoading(i))) ··· 39 77 // Check if we have enough packages to compare 40 78 const canCompare = computed(() => packages.value.length >= 2) 41 79 42 - // Get headers for the grid 43 - const gridHeaders = computed(() => { 44 - if (!packagesData.value) return packages.value 45 - return packagesData.value.map((p, i) => 46 - p ? `${p.package.name}@${p.package.version}` : (packages.value[i] ?? ''), 47 - ) 48 - }) 80 + // Extract headers from columns for facet rows 81 + const gridHeaders = computed(() => gridColumns.value.map(col => col.header)) 49 82 50 83 useSeoMeta({ 51 84 title: () => ··· 103 136 {{ $t('compare.packages.section_packages') }} 104 137 </h2> 105 138 <ComparePackageSelector v-model="packages" :max="4" /> 139 + 140 + <!-- "No dep" replacement suggestions (native, simple) --> 141 + <div v-if="noDepSuggestions.length > 0" class="mt-3 space-y-2"> 142 + <CompareReplacementSuggestion 143 + v-for="suggestion in noDepSuggestions" 144 + :key="suggestion.forPackage" 145 + :package-name="suggestion.forPackage" 146 + :replacement="suggestion.replacement" 147 + variant="nodep" 148 + :show-action="canAddNoDep" 149 + @add-no-dep="addNoDep" 150 + /> 151 + </div> 152 + 153 + <!-- Informational replacement suggestions (documented) --> 154 + <div v-if="infoSuggestions.length > 0" class="mt-3 space-y-2"> 155 + <CompareReplacementSuggestion 156 + v-for="suggestion in infoSuggestions" 157 + :key="suggestion.forPackage" 158 + :package-name="suggestion.forPackage" 159 + :replacement="suggestion.replacement" 160 + variant="info" 161 + /> 162 + </div> 106 163 </section> 107 164 108 165 <!-- Facet selector --> ··· 152 209 <div v-else-if="packagesData && packagesData.some(p => p !== null)"> 153 210 <!-- Desktop: Grid layout --> 154 211 <div class="hidden md:block overflow-x-auto"> 155 - <CompareComparisonGrid :columns="packages.length" :headers="gridHeaders"> 212 + <CompareComparisonGrid :columns="gridColumns" :show-no-dependency="showNoDependency"> 156 213 <CompareFacetRow 157 214 v-for="facet in selectedFacets" 158 215 :key="facet.id" ··· 189 246 {{ $t('package.downloads.title') }} 190 247 </h2> 191 248 192 - <CompareLineChart :packages /> 249 + <CompareLineChart :packages="packages.filter(p => p !== NO_DEPENDENCY_ID)" /> 193 250 </div> 194 251 195 252 <div v-else class="text-center py-12" role="alert">
+14 -2
i18n/locales/en.json
··· 139 139 "documented": "The {community} has flagged this package as having more performant alternatives.", 140 140 "none": "This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.", 141 141 "learn_more": "Learn more", 142 + "learn_more_above": "Learn more above.", 142 143 "mdn": "MDN", 143 - "community": "community" 144 + "community": "community", 145 + "consider_no_dep": "+ Consider no dep?" 144 146 }, 145 147 "stats": { 146 148 "license": "License", ··· 882 884 "loading_versions": "Loading versions...", 883 885 "select_version": "Select version" 884 886 }, 887 + "no_dependency": { 888 + "label": "(No dependency)", 889 + "typeahead_title": "What Would James Do?", 890 + "typeahead_description": "Compare against not using a dependency! e18e approved.", 891 + "tooltip_title": "You might not need a dependency", 892 + "tooltip_description": "Compare against not using a dependency! The {link} maintains a list of packages that can be replaced with native APIs or simpler alternatives.", 893 + "e18e_community": "e18e community", 894 + "add_column": "Add no dependency column to comparison" 895 + }, 885 896 "facets": { 886 897 "group_label": "Comparison facets", 887 898 "all": "all", ··· 956 967 "not_deprecated": "No", 957 968 "types_included": "Included", 958 969 "types_none": "None", 959 - "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 970 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 971 + "up_to_you": "Up to you!" 960 972 } 961 973 } 962 974 }
+14 -2
i18n/locales/fr-FR.json
··· 135 135 "documented": "La {community} a signalé que ce paquet a des alternatives plus performantes.", 136 136 "none": "Ce paquet a été signalé comme n'étant plus nécessaire, et sa fonctionnalité est probablement disponible nativement dans tous les moteurs.", 137 137 "learn_more": "En savoir plus", 138 + "learn_more_above": "En savoir plus ci-dessus.", 138 139 "mdn": "MDN", 139 - "community": "communauté" 140 + "community": "communauté", 141 + "consider_no_dep": "+ Envisager sans dépendance ?" 140 142 }, 141 143 "stats": { 142 144 "license": "Licence", ··· 854 856 "loading_versions": "Chargement des versions...", 855 857 "select_version": "Sélectionner une version" 856 858 }, 859 + "no_dependency": { 860 + "label": "(Sans dépendance)", 861 + "typeahead_title": "Et sans dépendance ?", 862 + "typeahead_description": "Comparer avec l'absence de dépendance ! Approuvé par e18e.", 863 + "tooltip_title": "Vous n'avez peut-être pas besoin d'une dépendance", 864 + "tooltip_description": "Comparer avec l'absence de dépendance ! La {link} maintient une liste de paquets pouvant être remplacés par des API natives ou des alternatives plus simples.", 865 + "e18e_community": "communauté e18e", 866 + "add_column": "Ajouter la colonne sans dépendance à la comparaison" 867 + }, 857 868 "facets": { 858 869 "group_label": "Facettes de comparaison", 859 870 "all": "tout", ··· 928 939 "not_deprecated": "Non", 929 940 "types_included": "Inclus", 930 941 "types_none": "Aucun", 931 - "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 942 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 943 + "up_to_you": "À vous de décider !" 932 944 } 933 945 } 934 946 }
+14 -2
lunaria/files/en-GB.json
··· 139 139 "documented": "The {community} has flagged this package as having more performant alternatives.", 140 140 "none": "This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.", 141 141 "learn_more": "Learn more", 142 + "learn_more_above": "Learn more above.", 142 143 "mdn": "MDN", 143 - "community": "community" 144 + "community": "community", 145 + "consider_no_dep": "+ Consider no dep?" 144 146 }, 145 147 "stats": { 146 148 "license": "License", ··· 882 884 "loading_versions": "Loading versions...", 883 885 "select_version": "Select version" 884 886 }, 887 + "no_dependency": { 888 + "label": "(No dependency)", 889 + "typeahead_title": "What Would James Do?", 890 + "typeahead_description": "Compare against not using a dependency! e18e approved.", 891 + "tooltip_title": "You might not need a dependency", 892 + "tooltip_description": "Compare against not using a dependency! The {link} maintains a list of packages that can be replaced with native APIs or simpler alternatives.", 893 + "e18e_community": "e18e community", 894 + "add_column": "Add no dependency column to comparison" 895 + }, 885 896 "facets": { 886 897 "group_label": "Comparison facets", 887 898 "all": "all", ··· 956 967 "not_deprecated": "No", 957 968 "types_included": "Included", 958 969 "types_none": "None", 959 - "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 970 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 971 + "up_to_you": "Up to you!" 960 972 } 961 973 } 962 974 }
+14 -2
lunaria/files/en-US.json
··· 139 139 "documented": "The {community} has flagged this package as having more performant alternatives.", 140 140 "none": "This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.", 141 141 "learn_more": "Learn more", 142 + "learn_more_above": "Learn more above.", 142 143 "mdn": "MDN", 143 - "community": "community" 144 + "community": "community", 145 + "consider_no_dep": "+ Consider no dep?" 144 146 }, 145 147 "stats": { 146 148 "license": "License", ··· 882 884 "loading_versions": "Loading versions...", 883 885 "select_version": "Select version" 884 886 }, 887 + "no_dependency": { 888 + "label": "(No dependency)", 889 + "typeahead_title": "What Would James Do?", 890 + "typeahead_description": "Compare against not using a dependency! e18e approved.", 891 + "tooltip_title": "You might not need a dependency", 892 + "tooltip_description": "Compare against not using a dependency! The {link} maintains a list of packages that can be replaced with native APIs or simpler alternatives.", 893 + "e18e_community": "e18e community", 894 + "add_column": "Add no dependency column to comparison" 895 + }, 885 896 "facets": { 886 897 "group_label": "Comparison facets", 887 898 "all": "all", ··· 956 967 "not_deprecated": "No", 957 968 "types_included": "Included", 958 969 "types_none": "None", 959 - "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 970 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 971 + "up_to_you": "Up to you!" 960 972 } 961 973 } 962 974 }
+14 -2
lunaria/files/fr-FR.json
··· 135 135 "documented": "La {community} a signalé que ce paquet a des alternatives plus performantes.", 136 136 "none": "Ce paquet a été signalé comme n'étant plus nécessaire, et sa fonctionnalité est probablement disponible nativement dans tous les moteurs.", 137 137 "learn_more": "En savoir plus", 138 + "learn_more_above": "En savoir plus ci-dessus.", 138 139 "mdn": "MDN", 139 - "community": "communauté" 140 + "community": "communauté", 141 + "consider_no_dep": "+ Envisager sans dépendance ?" 140 142 }, 141 143 "stats": { 142 144 "license": "Licence", ··· 854 856 "loading_versions": "Chargement des versions...", 855 857 "select_version": "Sélectionner une version" 856 858 }, 859 + "no_dependency": { 860 + "label": "(Sans dépendance)", 861 + "typeahead_title": "Et sans dépendance ?", 862 + "typeahead_description": "Comparer avec l'absence de dépendance ! Approuvé par e18e.", 863 + "tooltip_title": "Vous n'avez peut-être pas besoin d'une dépendance", 864 + "tooltip_description": "Comparer avec l'absence de dépendance ! La {link} maintient une liste de paquets pouvant être remplacés par des API natives ou des alternatives plus simples.", 865 + "e18e_community": "communauté e18e", 866 + "add_column": "Ajouter la colonne sans dépendance à la comparaison" 867 + }, 857 868 "facets": { 858 869 "group_label": "Facettes de comparaison", 859 870 "all": "tout", ··· 928 939 "not_deprecated": "Non", 929 940 "types_included": "Inclus", 930 941 "types_none": "Aucun", 931 - "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 942 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)", 943 + "up_to_you": "À vous de décider !" 932 944 } 933 945 } 934 946 }
+49
test/e2e/interactions.spec.ts
··· 1 1 import { expect, test } from './test-utils' 2 2 3 + test.describe('Compare Page', () => { 4 + test('no-dep column renders separately from package columns', async ({ page, goto }) => { 5 + await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' }) 6 + 7 + const grid = page.locator('.comparison-grid') 8 + await expect(grid).toBeVisible({ timeout: 15000 }) 9 + 10 + // Should have the no-dep column with special styling 11 + const noDepColumn = grid.locator('.comparison-cell-nodep') 12 + await expect(noDepColumn).toBeVisible() 13 + 14 + // The no-dep column should not contain a link 15 + await expect(noDepColumn.locator('a')).toHaveCount(0) 16 + }) 17 + 18 + test('no-dep column is always last even when packages are added after', async ({ 19 + page, 20 + goto, 21 + }) => { 22 + // Start with vue and no-dep 23 + await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' }) 24 + 25 + const grid = page.locator('.comparison-grid') 26 + await expect(grid).toBeVisible({ timeout: 15000 }) 27 + 28 + // Add another package via the input 29 + const input = page.locator('#package-search') 30 + await input.fill('nuxt') 31 + 32 + // Wait for search results and click on nuxt 33 + const nuxtOption = page.locator('button:has-text("nuxt")').first() 34 + await expect(nuxtOption).toBeVisible({ timeout: 10000 }) 35 + await nuxtOption.click() 36 + 37 + // URL should have no-dep at the end, not in the middle 38 + await expect(page).toHaveURL(/packages=vue,nuxt,__no_dependency__/) 39 + 40 + // Verify column order in the grid: vue, nuxt, then no-dep 41 + const headerLinks = grid.locator('.comparison-cell-header a.truncate') 42 + await expect(headerLinks).toHaveCount(2) 43 + await expect(headerLinks.nth(0)).toContainText('vue') 44 + await expect(headerLinks.nth(1)).toContainText('nuxt') 45 + 46 + // No-dep should still be visible as the last column 47 + const noDepColumn = grid.locator('.comparison-cell-nodep') 48 + await expect(noDepColumn).toBeVisible() 49 + }) 50 + }) 51 + 3 52 test.describe('Search Pages', () => { 4 53 test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => { 5 54 await goto('/search?q=vue', { waitUntil: 'hydration' })
+69 -4
test/nuxt/a11y.spec.ts
··· 94 94 CompareFacetSelector, 95 95 CompareLineChart, 96 96 ComparePackageSelector, 97 + CompareReplacementSuggestion, 97 98 DateTime, 98 99 DependencyPathPopup, 99 100 FilterChips, ··· 1607 1608 it('should have no accessibility violations with 2 columns', async () => { 1608 1609 const component = await mountSuspended(CompareComparisonGrid, { 1609 1610 props: { 1610 - columns: 2, 1611 - headers: ['vue', 'react'], 1611 + columns: [{ header: 'vue' }, { header: 'react' }], 1612 1612 }, 1613 1613 slots: { 1614 1614 default: '<div>Grid content</div>', ··· 1621 1621 it('should have no accessibility violations with 3 columns', async () => { 1622 1622 const component = await mountSuspended(CompareComparisonGrid, { 1623 1623 props: { 1624 - columns: 3, 1625 - headers: ['vue', 'react', 'angular'], 1624 + columns: [{ header: 'vue' }, { header: 'react' }, { header: 'angular' }], 1626 1625 }, 1627 1626 slots: { 1628 1627 default: '<div>Grid content</div>', 1628 + }, 1629 + }) 1630 + const results = await runAxe(component) 1631 + expect(results.violations).toEqual([]) 1632 + }) 1633 + 1634 + it('should have no accessibility violations with no-dependency column', async () => { 1635 + const component = await mountSuspended(CompareComparisonGrid, { 1636 + props: { 1637 + columns: [{ header: 'vue' }, { header: 'react' }], 1638 + showNoDependency: true, 1639 + }, 1640 + slots: { 1641 + default: '<div>Grid content</div>', 1642 + }, 1643 + }) 1644 + const results = await runAxe(component) 1645 + expect(results.violations).toEqual([]) 1646 + }) 1647 + }) 1648 + 1649 + describe('CompareReplacementSuggestion', () => { 1650 + it('should have no accessibility violations for nodep variant with native replacement', async () => { 1651 + const component = await mountSuspended(CompareReplacementSuggestion, { 1652 + props: { 1653 + packageName: 'array-includes', 1654 + replacement: { 1655 + type: 'native', 1656 + moduleName: 'array-includes', 1657 + nodeVersion: '6.0.0', 1658 + replacement: 'Array.prototype.includes', 1659 + mdnPath: 'Global_Objects/Array/includes', 1660 + }, 1661 + variant: 'nodep', 1662 + }, 1663 + }) 1664 + const results = await runAxe(component) 1665 + expect(results.violations).toEqual([]) 1666 + }) 1667 + 1668 + it('should have no accessibility violations for nodep variant with simple replacement', async () => { 1669 + const component = await mountSuspended(CompareReplacementSuggestion, { 1670 + props: { 1671 + packageName: 'is-even', 1672 + replacement: { 1673 + type: 'simple', 1674 + moduleName: 'is-even', 1675 + replacement: 'Use (n % 2) === 0', 1676 + }, 1677 + variant: 'nodep', 1678 + }, 1679 + }) 1680 + const results = await runAxe(component) 1681 + expect(results.violations).toEqual([]) 1682 + }) 1683 + 1684 + it('should have no accessibility violations for info variant with documented replacement', async () => { 1685 + const component = await mountSuspended(CompareReplacementSuggestion, { 1686 + props: { 1687 + packageName: 'moment', 1688 + replacement: { 1689 + type: 'documented', 1690 + moduleName: 'moment', 1691 + docPath: 'moment', 1692 + }, 1693 + variant: 'info', 1629 1694 }, 1630 1695 }) 1631 1696 const results = await runAxe(component)
+31 -24
test/nuxt/components/compare/ComparisonGrid.spec.ts
··· 2 2 import { mountSuspended } from '@nuxt/test-utils/runtime' 3 3 import ComparisonGrid from '~/components/Compare/ComparisonGrid.vue' 4 4 5 + function cols(...headers: string[]) { 6 + return headers.map(header => ({ header })) 7 + } 8 + 5 9 describe('ComparisonGrid', () => { 6 10 describe('header rendering', () => { 7 11 it('renders column headers', async () => { 8 12 const component = await mountSuspended(ComparisonGrid, { 9 13 props: { 10 - columns: 2, 11 - headers: ['lodash@4.17.21', 'underscore@1.13.6'], 14 + columns: cols('lodash@4.17.21', 'underscore@1.13.6'), 12 15 }, 13 16 }) 14 17 expect(component.text()).toContain('lodash@4.17.21') ··· 18 21 it('renders correct number of header cells', async () => { 19 22 const component = await mountSuspended(ComparisonGrid, { 20 23 props: { 21 - columns: 3, 22 - headers: ['pkg1', 'pkg2', 'pkg3'], 24 + columns: cols('pkg1', 'pkg2', 'pkg3'), 23 25 }, 24 26 }) 25 27 const headerCells = component.findAll('.comparison-cell-header') 26 28 expect(headerCells.length).toBe(3) 27 29 }) 28 30 31 + it('adds no-dep column when showNoDependency is true', async () => { 32 + const component = await mountSuspended(ComparisonGrid, { 33 + props: { 34 + columns: cols('vue', 'react'), 35 + showNoDependency: true, 36 + }, 37 + }) 38 + const headerCells = component.findAll('.comparison-cell-header') 39 + expect(headerCells.length).toBe(3) 40 + expect(component.find('.comparison-cell-nodep').exists()).toBe(true) 41 + }) 42 + 29 43 it('truncates long header text with title attribute', async () => { 30 44 const longName = 'very-long-package-name@1.0.0-beta.1' 31 45 const component = await mountSuspended(ComparisonGrid, { 32 46 props: { 33 - columns: 2, 34 - headers: [longName, 'short'], 47 + columns: cols(longName, 'short'), 35 48 }, 36 49 }) 37 - const spans = component.findAll('.truncate') 38 - const longSpan = spans.find(s => s.text() === longName) 39 - expect(longSpan?.attributes('title')).toBe(longName) 50 + const links = component.findAll('a.truncate') 51 + const longLink = links.find(a => a.text() === longName) 52 + expect(longLink?.attributes('title')).toBe(longName) 40 53 }) 41 54 }) 42 55 ··· 44 57 it('applies columns-2 class for 2 columns', async () => { 45 58 const component = await mountSuspended(ComparisonGrid, { 46 59 props: { 47 - columns: 2, 48 - headers: ['a', 'b'], 60 + columns: cols('a', 'b'), 49 61 }, 50 62 }) 51 63 expect(component.find('.columns-2').exists()).toBe(true) 52 64 }) 53 65 54 - it('applies columns-3 class for 3 columns', async () => { 66 + it('applies columns-3 class for 2 packages + no-dep', async () => { 55 67 const component = await mountSuspended(ComparisonGrid, { 56 68 props: { 57 - columns: 3, 58 - headers: ['a', 'b', 'c'], 69 + columns: cols('a', 'b'), 70 + showNoDependency: true, 59 71 }, 60 72 }) 61 73 expect(component.find('.columns-3').exists()).toBe(true) ··· 64 76 it('applies columns-4 class for 4 columns', async () => { 65 77 const component = await mountSuspended(ComparisonGrid, { 66 78 props: { 67 - columns: 4, 68 - headers: ['a', 'b', 'c', 'd'], 79 + columns: cols('a', 'b', 'c', 'd'), 69 80 }, 70 81 }) 71 82 expect(component.find('.columns-4').exists()).toBe(true) ··· 74 85 it('sets min-width for 4 columns to 800px', async () => { 75 86 const component = await mountSuspended(ComparisonGrid, { 76 87 props: { 77 - columns: 4, 78 - headers: ['a', 'b', 'c', 'd'], 88 + columns: cols('a', 'b', 'c', 'd'), 79 89 }, 80 90 }) 81 91 expect(component.find('.min-w-\\[800px\\]').exists()).toBe(true) ··· 84 94 it('sets min-width for 2-3 columns to 600px', async () => { 85 95 const component = await mountSuspended(ComparisonGrid, { 86 96 props: { 87 - columns: 2, 88 - headers: ['a', 'b'], 97 + columns: cols('a', 'b'), 89 98 }, 90 99 }) 91 100 expect(component.find('.min-w-\\[600px\\]').exists()).toBe(true) ··· 94 103 it('sets --columns CSS variable', async () => { 95 104 const component = await mountSuspended(ComparisonGrid, { 96 105 props: { 97 - columns: 3, 98 - headers: ['a', 'b', 'c'], 106 + columns: cols('a', 'b', 'c'), 99 107 }, 100 108 }) 101 109 const grid = component.find('.comparison-grid') ··· 107 115 it('renders default slot content', async () => { 108 116 const component = await mountSuspended(ComparisonGrid, { 109 117 props: { 110 - columns: 2, 111 - headers: ['a', 'b'], 118 + columns: cols('a', 'b'), 112 119 }, 113 120 slots: { 114 121 default: '<div class="test-row">Row content</div>',
+264
test/nuxt/composables/use-compare-replacements.spec.ts
··· 1 + import { afterEach, describe, expect, it, vi } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import type { ModuleReplacement } from 'module-replacements' 4 + import type { ReplacementSuggestion } from '~/composables/useCompareReplacements' 5 + 6 + /** 7 + * Helper to test useCompareReplacements by wrapping it in a component. 8 + */ 9 + async function useCompareReplacementsInComponent(packageNames: string[]) { 10 + const capturedNoDepSuggestions = ref<ReplacementSuggestion[]>([]) 11 + const capturedInfoSuggestions = ref<ReplacementSuggestion[]>([]) 12 + const capturedLoading = ref(false) 13 + const capturedReplacements = ref(new Map<string, ModuleReplacement | null>()) 14 + 15 + const WrapperComponent = defineComponent({ 16 + setup() { 17 + const { noDepSuggestions, infoSuggestions, loading, replacements } = 18 + useCompareReplacements(packageNames) 19 + 20 + watchEffect(() => { 21 + capturedNoDepSuggestions.value = [...noDepSuggestions.value] 22 + capturedInfoSuggestions.value = [...infoSuggestions.value] 23 + capturedLoading.value = loading.value 24 + capturedReplacements.value = new Map(replacements.value) 25 + }) 26 + 27 + return () => h('div') 28 + }, 29 + }) 30 + 31 + await mountSuspended(WrapperComponent) 32 + 33 + return { 34 + noDepSuggestions: capturedNoDepSuggestions, 35 + infoSuggestions: capturedInfoSuggestions, 36 + loading: capturedLoading, 37 + replacements: capturedReplacements, 38 + } 39 + } 40 + 41 + describe('useCompareReplacements', () => { 42 + afterEach(() => { 43 + vi.unstubAllGlobals() 44 + }) 45 + 46 + describe('suggestion categorization', () => { 47 + it('categorizes native replacements as no dep suggestions', async () => { 48 + vi.stubGlobal( 49 + '$fetch', 50 + vi.fn().mockImplementation((url: string) => { 51 + if (url.includes('/api/replacements/array-includes')) { 52 + return Promise.resolve({ 53 + type: 'native', 54 + moduleName: 'array-includes', 55 + nodeVersion: '6.0.0', 56 + replacement: 'Array.prototype.includes', 57 + mdnPath: 'Global_Objects/Array/includes', 58 + category: 'native', 59 + } satisfies ModuleReplacement) 60 + } 61 + return Promise.resolve(null) 62 + }), 63 + ) 64 + 65 + const { noDepSuggestions, infoSuggestions } = await useCompareReplacementsInComponent([ 66 + 'array-includes', 67 + ]) 68 + 69 + await vi.waitFor(() => { 70 + expect(noDepSuggestions.value).toHaveLength(1) 71 + }) 72 + 73 + expect(noDepSuggestions.value[0]?.forPackage).toBe('array-includes') 74 + expect(noDepSuggestions.value[0]?.replacement.type).toBe('native') 75 + expect(infoSuggestions.value).toHaveLength(0) 76 + }) 77 + 78 + it('categorizes simple replacements as no dep suggestions', async () => { 79 + vi.stubGlobal( 80 + '$fetch', 81 + vi.fn().mockImplementation((url: string) => { 82 + if (url.includes('/api/replacements/is-even')) { 83 + return Promise.resolve({ 84 + type: 'simple', 85 + moduleName: 'is-even', 86 + replacement: 'Use (n % 2) === 0', 87 + category: 'micro-utilities', 88 + } satisfies ModuleReplacement) 89 + } 90 + return Promise.resolve(null) 91 + }), 92 + ) 93 + 94 + const { noDepSuggestions, infoSuggestions } = await useCompareReplacementsInComponent([ 95 + 'is-even', 96 + ]) 97 + 98 + await vi.waitFor(() => { 99 + expect(noDepSuggestions.value).toHaveLength(1) 100 + }) 101 + 102 + expect(noDepSuggestions.value[0]?.forPackage).toBe('is-even') 103 + expect(noDepSuggestions.value[0]?.replacement.type).toBe('simple') 104 + expect(infoSuggestions.value).toHaveLength(0) 105 + }) 106 + 107 + it('categorizes documented replacements as info suggestions', async () => { 108 + vi.stubGlobal( 109 + '$fetch', 110 + vi.fn().mockImplementation((url: string) => { 111 + if (url.includes('/api/replacements/moment')) { 112 + return Promise.resolve({ 113 + type: 'documented', 114 + moduleName: 'moment', 115 + docPath: 'moment', 116 + category: 'preferred', 117 + } satisfies ModuleReplacement) 118 + } 119 + return Promise.resolve(null) 120 + }), 121 + ) 122 + 123 + const { noDepSuggestions, infoSuggestions } = await useCompareReplacementsInComponent([ 124 + 'moment', 125 + ]) 126 + 127 + await vi.waitFor(() => { 128 + expect(infoSuggestions.value).toHaveLength(1) 129 + }) 130 + 131 + expect(infoSuggestions.value[0]?.forPackage).toBe('moment') 132 + expect(infoSuggestions.value[0]?.replacement.type).toBe('documented') 133 + expect(noDepSuggestions.value).toHaveLength(0) 134 + }) 135 + 136 + it('correctly splits multiple packages into no dep and info categories', async () => { 137 + vi.stubGlobal( 138 + '$fetch', 139 + vi.fn().mockImplementation((url: string) => { 140 + if (url.includes('/api/replacements/is-odd')) { 141 + return Promise.resolve({ 142 + type: 'simple', 143 + moduleName: 'is-odd', 144 + replacement: 'Use (n % 2) !== 0', 145 + category: 'micro-utilities', 146 + } satisfies ModuleReplacement) 147 + } 148 + if (url.includes('/api/replacements/lodash')) { 149 + return Promise.resolve({ 150 + type: 'documented', 151 + moduleName: 'lodash', 152 + docPath: 'lodash-underscore', 153 + category: 'preferred', 154 + } satisfies ModuleReplacement) 155 + } 156 + if (url.includes('/api/replacements/array-map')) { 157 + return Promise.resolve({ 158 + type: 'native', 159 + moduleName: 'array-map', 160 + nodeVersion: '0.10.0', 161 + replacement: 'Array.prototype.map', 162 + mdnPath: 'Global_Objects/Array/map', 163 + category: 'native', 164 + } satisfies ModuleReplacement) 165 + } 166 + return Promise.resolve(null) 167 + }), 168 + ) 169 + 170 + const { noDepSuggestions, infoSuggestions } = await useCompareReplacementsInComponent([ 171 + 'is-odd', 172 + 'lodash', 173 + 'array-map', 174 + ]) 175 + 176 + await vi.waitFor(() => { 177 + expect(noDepSuggestions.value).toHaveLength(2) 178 + expect(infoSuggestions.value).toHaveLength(1) 179 + }) 180 + 181 + // no dep should have simple and native 182 + const noDepTypes = noDepSuggestions.value.map(s => s.replacement.type) 183 + expect(noDepTypes).toContain('simple') 184 + expect(noDepTypes).toContain('native') 185 + 186 + // Info should have documented 187 + expect(infoSuggestions.value[0]?.replacement.type).toBe('documented') 188 + }) 189 + }) 190 + 191 + describe('packages without replacements', () => { 192 + it('does not include packages with no replacement data', async () => { 193 + vi.stubGlobal( 194 + '$fetch', 195 + vi.fn().mockImplementation((url: string) => { 196 + if (url.includes('/api/replacements/react')) { 197 + return Promise.resolve(null) // No replacement for react 198 + } 199 + return Promise.resolve(null) 200 + }), 201 + ) 202 + 203 + const { noDepSuggestions, infoSuggestions, replacements } = 204 + await useCompareReplacementsInComponent(['react']) 205 + 206 + await vi.waitFor(() => { 207 + expect(replacements.value.has('react')).toBe(true) 208 + }) 209 + 210 + expect(noDepSuggestions.value).toHaveLength(0) 211 + expect(infoSuggestions.value).toHaveLength(0) 212 + }) 213 + 214 + it('handles fetch errors gracefully', async () => { 215 + vi.stubGlobal( 216 + '$fetch', 217 + vi.fn().mockImplementation(() => { 218 + return Promise.reject(new Error('Network error')) 219 + }), 220 + ) 221 + 222 + const { noDepSuggestions, infoSuggestions, replacements } = 223 + await useCompareReplacementsInComponent(['some-package']) 224 + 225 + await vi.waitFor(() => { 226 + expect(replacements.value.has('some-package')).toBe(true) 227 + }) 228 + 229 + expect(replacements.value.get('some-package')).toBeNull() 230 + expect(noDepSuggestions.value).toHaveLength(0) 231 + expect(infoSuggestions.value).toHaveLength(0) 232 + }) 233 + }) 234 + 235 + describe('caching', () => { 236 + it('caches replacement data and does not refetch', async () => { 237 + const fetchMock = vi.fn().mockImplementation((url: string) => { 238 + if (url.includes('/api/replacements/is-even')) { 239 + return Promise.resolve({ 240 + type: 'simple', 241 + moduleName: 'is-even', 242 + replacement: 'Use (n % 2) === 0', 243 + category: 'micro-utilities', 244 + } satisfies ModuleReplacement) 245 + } 246 + return Promise.resolve(null) 247 + }) 248 + 249 + vi.stubGlobal('$fetch', fetchMock) 250 + 251 + const { noDepSuggestions } = await useCompareReplacementsInComponent(['is-even']) 252 + 253 + await vi.waitFor(() => { 254 + expect(noDepSuggestions.value).toHaveLength(1) 255 + }) 256 + 257 + // Check that fetch was called exactly once for is-even 258 + const isEvenCalls = fetchMock.mock.calls.filter( 259 + (call: unknown[]) => typeof call[0] === 'string' && call[0].includes('is-even'), 260 + ) 261 + expect(isEvenCalls).toHaveLength(1) 262 + }) 263 + }) 264 + })