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

feat: show replaceable dependencies (#1468)

authored by

James Garbutt and committed by
GitHub
295520c6 9e345ef7

+218 -5
+33 -2
app/components/Package/Dependencies.vue
··· 2 2 import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity' 3 3 import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies' 4 4 5 + const { t } = useI18n() 6 + 5 7 const props = defineProps<{ 6 8 packageName: string 7 9 version: string ··· 13 15 14 16 // Fetch outdated info for dependencies 15 17 const outdatedDeps = useOutdatedDependencies(() => props.dependencies) 18 + 19 + // Fetch replacement suggestions for dependencies 20 + const replacementDeps = useReplacementDependencies(() => props.dependencies) 16 21 17 22 // Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree) 18 23 const { data: vulnTree } = useDependencyAnalysis( ··· 66 71 return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b)) 67 72 }) 68 73 74 + // Get version tooltip 75 + function getDepVersionTooltip(dep: string, version: string) { 76 + const outdated = outdatedDeps.value[dep] 77 + if (outdated) return getOutdatedTooltip(outdated, t) 78 + if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return version 79 + if (replacementDeps.value[dep]) return t('package.dependencies.has_replacement') 80 + return version 81 + } 82 + 83 + // Get version class 84 + function getDepVersionClass(dep: string) { 85 + const outdated = outdatedDeps.value[dep] 86 + if (outdated) return getVersionClass(outdated) 87 + if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return getVersionClass(undefined) 88 + if (replacementDeps.value[dep]) return 'text-amber-700 dark:text-amber-500' 89 + return getVersionClass(undefined) 90 + } 91 + 69 92 const numberFormatter = useNumberFormatter() 70 93 </script> 71 94 ··· 104 127 > 105 128 <span class="i-lucide:circle-alert w-3 h-3" /> 106 129 </TooltipApp> 130 + <TooltipApp 131 + v-if="replacementDeps[dep]" 132 + class="shrink-0 p-2 -m-2 text-amber-700 dark:text-amber-500" 133 + aria-hidden="true" 134 + :text="$t('package.dependencies.has_replacement')" 135 + > 136 + <span class="i-carbon:idea w-3 h-3" /> 137 + </TooltipApp> 107 138 <LinkBase 108 139 v-if="getVulnerableDepInfo(dep)" 109 140 :to="packageRoute(dep, getVulnerableDepInfo(dep)!.version)" ··· 126 157 <LinkBase 127 158 :to="packageRoute(dep, version)" 128 159 class="block truncate" 129 - :class="getVersionClass(outdatedDeps[dep])" 130 - :title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep], $t) : version" 160 + :class="getDepVersionClass(dep)" 161 + :title="getDepVersionTooltip(dep, version)" 131 162 > 132 163 {{ version }} 133 164 </LinkBase>
+63
app/composables/npm/useReplacementDependencies.ts
··· 1 + import type { ModuleReplacement } from 'module-replacements' 2 + 3 + async function fetchReplacements( 4 + deps: Record<string, string>, 5 + ): Promise<Record<string, ModuleReplacement>> { 6 + const names = Object.keys(deps) 7 + 8 + const results = await Promise.all( 9 + names.map(async name => { 10 + try { 11 + const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`) 12 + return { name, replacement } 13 + } catch { 14 + return { name, replacement: null } 15 + } 16 + }), 17 + ) 18 + 19 + const map: Record<string, ModuleReplacement> = {} 20 + for (const { name, replacement } of results) { 21 + if (replacement) { 22 + map[name] = replacement 23 + } 24 + } 25 + return map 26 + } 27 + 28 + /** 29 + * Fetch module replacement suggestions for a set of dependencies. 30 + * Returns a reactive map of dependency name to ModuleReplacement. 31 + */ 32 + export function useReplacementDependencies( 33 + dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 34 + ) { 35 + const replacements = shallowRef<Record<string, ModuleReplacement>>({}) 36 + let generation = 0 37 + 38 + if (import.meta.client) { 39 + watch( 40 + () => toValue(dependencies), 41 + async deps => { 42 + const currentGeneration = ++generation 43 + 44 + if (!deps || Object.keys(deps).length === 0) { 45 + replacements.value = {} 46 + return 47 + } 48 + 49 + try { 50 + const result = await fetchReplacements(deps) 51 + if (currentGeneration === generation) { 52 + replacements.value = result 53 + } 54 + } catch { 55 + // catastrophic failure, just keep whatever we have 56 + } 57 + }, 58 + { immediate: true }, 59 + ) 60 + } 61 + 62 + return replacements 63 + }
+2 -1
i18n/locales/en.json
··· 316 316 "view_vulnerabilities": "View vulnerabilities", 317 317 "outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})", 318 318 "outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})", 319 - "outdated_patch": "Patch update available (latest: {latest})" 319 + "outdated_patch": "Patch update available (latest: {latest})", 320 + "has_replacement": "This dependency has suggested replacements" 320 321 }, 321 322 "peer_dependencies": { 322 323 "title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
+3
i18n/schema.json
··· 954 954 }, 955 955 "outdated_patch": { 956 956 "type": "string" 957 + }, 958 + "has_replacement": { 959 + "type": "string" 957 960 } 958 961 }, 959 962 "additionalProperties": false
+2 -1
lunaria/files/en-GB.json
··· 315 315 "view_vulnerabilities": "View vulnerabilities", 316 316 "outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})", 317 317 "outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})", 318 - "outdated_patch": "Patch update available (latest: {latest})" 318 + "outdated_patch": "Patch update available (latest: {latest})", 319 + "has_replacement": "This dependency has suggested replacements" 319 320 }, 320 321 "peer_dependencies": { 321 322 "title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
+2 -1
lunaria/files/en-US.json
··· 315 315 "view_vulnerabilities": "View vulnerabilities", 316 316 "outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})", 317 317 "outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})", 318 - "outdated_patch": "Patch update available (latest: {latest})" 318 + "outdated_patch": "Patch update available (latest: {latest})", 319 + "has_replacement": "This dependency has suggested replacements" 319 320 }, 320 321 "peer_dependencies": { 321 322 "title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
+113
test/nuxt/composables/use-replacement-dependencies.spec.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' 3 + import type { ModuleReplacement } from 'module-replacements' 4 + 5 + const SIMPLE_REPLACEMENT: ModuleReplacement = { 6 + type: 'simple', 7 + moduleName: 'is-even', 8 + replacement: 'Use (n % 2) === 0', 9 + category: 'micro-utilities', 10 + } 11 + 12 + const NATIVE_REPLACEMENT: ModuleReplacement = { 13 + type: 'native', 14 + moduleName: 'array-includes', 15 + nodeVersion: '6.0.0', 16 + replacement: 'Array.prototype.includes', 17 + mdnPath: 'Global_Objects/Array/includes', 18 + category: 'native', 19 + } 20 + 21 + async function mountWithDeps(deps: Record<string, string> | undefined) { 22 + const captured = ref<Record<string, ModuleReplacement>>({}) 23 + 24 + const WrapperComponent = defineComponent({ 25 + setup() { 26 + const replacements = useReplacementDependencies(() => deps) 27 + 28 + watchEffect(() => { 29 + captured.value = { ...replacements.value } 30 + }) 31 + 32 + return () => h('div') 33 + }, 34 + }) 35 + 36 + await mountSuspended(WrapperComponent) 37 + 38 + return captured 39 + } 40 + 41 + describe('useReplacementDependencies', () => { 42 + it('returns replacements for dependencies that have them', async () => { 43 + registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT) 44 + registerEndpoint('/api/replacements/picoquery', () => null) 45 + 46 + const replacements = await mountWithDeps({ 47 + 'is-even': '^1.0.0', 48 + 'picoquery': '^1.0.0', 49 + }) 50 + 51 + await vi.waitFor(() => { 52 + expect(replacements.value['is-even']).toBeDefined() 53 + }) 54 + 55 + expect(replacements.value['is-even']?.type).toBe('simple') 56 + expect(replacements.value['picoquery']).toBeUndefined() 57 + }) 58 + 59 + it('returns empty object for undefined dependencies', async () => { 60 + const replacements = await mountWithDeps(undefined) 61 + 62 + await vi.waitFor(() => { 63 + expect(replacements.value).toEqual({}) 64 + }) 65 + }) 66 + 67 + it('returns empty object for empty dependencies', async () => { 68 + const replacements = await mountWithDeps({}) 69 + 70 + await vi.waitFor(() => { 71 + expect(replacements.value).toEqual({}) 72 + }) 73 + }) 74 + 75 + it('handles multiple dependencies with replacements', async () => { 76 + registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT) 77 + registerEndpoint('/api/replacements/array-includes', () => NATIVE_REPLACEMENT) 78 + registerEndpoint('/api/replacements/picoquery', () => null) 79 + 80 + const replacements = await mountWithDeps({ 81 + 'is-even': '^1.0.0', 82 + 'array-includes': '^3.0.0', 83 + 'picoquery': '^1.0.0', 84 + }) 85 + 86 + await vi.waitFor(() => { 87 + expect(Object.keys(replacements.value)).toHaveLength(2) 88 + }) 89 + 90 + expect(replacements.value['is-even']?.type).toBe('simple') 91 + expect(replacements.value['array-includes']?.type).toBe('native') 92 + expect(replacements.value['picoquery']).toBeUndefined() 93 + }) 94 + 95 + it('handles fetch errors gracefully', async () => { 96 + registerEndpoint('/api/replacements/failing-package', () => { 97 + throw new Error('Network error') 98 + }) 99 + registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT) 100 + 101 + const replacements = await mountWithDeps({ 102 + 'failing-package': '^1.0.0', 103 + 'is-even': '^1.0.0', 104 + }) 105 + 106 + await vi.waitFor(() => { 107 + expect(replacements.value['is-even']).toBeDefined() 108 + }) 109 + 110 + expect(replacements.value['failing-package']).toBeUndefined() 111 + expect(replacements.value['is-even']?.type).toBe('simple') 112 + }) 113 + })