Skip to content

Commit 295520c

Browse files
authored
feat: show replaceable dependencies (#1468)
1 parent 9e345ef commit 295520c

File tree

7 files changed

+218
-5
lines changed

7 files changed

+218
-5
lines changed

app/components/Package/Dependencies.vue

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
33
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
44
5+
const { t } = useI18n()
6+
57
const props = defineProps<{
68
packageName: string
79
version: string
@@ -14,6 +16,9 @@ const props = defineProps<{
1416
// Fetch outdated info for dependencies
1517
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
1618
19+
// Fetch replacement suggestions for dependencies
20+
const replacementDeps = useReplacementDependencies(() => props.dependencies)
21+
1722
// Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree)
1823
const { data: vulnTree } = useDependencyAnalysis(
1924
() => props.packageName,
@@ -66,6 +71,24 @@ const sortedOptionalDependencies = computed(() => {
6671
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
6772
})
6873
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+
6992
const numberFormatter = useNumberFormatter()
7093
</script>
7194

@@ -104,6 +127,14 @@ const numberFormatter = useNumberFormatter()
104127
>
105128
<span class="i-lucide:circle-alert w-3 h-3" />
106129
</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>
107138
<LinkBase
108139
v-if="getVulnerableDepInfo(dep)"
109140
:to="packageRoute(dep, getVulnerableDepInfo(dep)!.version)"
@@ -126,8 +157,8 @@ const numberFormatter = useNumberFormatter()
126157
<LinkBase
127158
:to="packageRoute(dep, version)"
128159
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)"
131162
>
132163
{{ version }}
133164
</LinkBase>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
}

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@
316316
"view_vulnerabilities": "View vulnerabilities",
317317
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
318318
"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"
320321
},
321322
"peer_dependencies": {
322323
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,9 @@
954954
},
955955
"outdated_patch": {
956956
"type": "string"
957+
},
958+
"has_replacement": {
959+
"type": "string"
957960
}
958961
},
959962
"additionalProperties": false

lunaria/files/en-GB.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@
315315
"view_vulnerabilities": "View vulnerabilities",
316316
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
317317
"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"
319320
},
320321
"peer_dependencies": {
321322
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",

lunaria/files/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@
315315
"view_vulnerabilities": "View vulnerabilities",
316316
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
317317
"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"
319320
},
320321
"peer_dependencies": {
321322
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
})

0 commit comments

Comments
 (0)