Skip to content

Commit a3f7b81

Browse files
committed
feat: show replaceable dependencies
This adds a replacements notice to the dependency list just like outdated, vulnerable, etc. Each dependency with community replacements shows an amber warning and tooltip.
1 parent cb1b4a4 commit a3f7b81

File tree

7 files changed

+214
-5
lines changed

7 files changed

+214
-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-carbon:warning-alt 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ShallowRef } from 'vue'
2+
import type { ModuleReplacement } from 'module-replacements'
3+
4+
async function fetchReplacements(
5+
deps: Record<string, string> | undefined,
6+
replacements: ShallowRef<Record<string, ModuleReplacement>>,
7+
) {
8+
if (!deps || Object.keys(deps).length === 0) {
9+
replacements.value = {}
10+
return
11+
}
12+
13+
const names = Object.keys(deps)
14+
15+
const results = await Promise.all(
16+
names.map(async name => {
17+
try {
18+
const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`)
19+
return { name, replacement }
20+
} catch {
21+
return { name, replacement: null }
22+
}
23+
}),
24+
)
25+
26+
const map: Record<string, ModuleReplacement> = {}
27+
for (const { name, replacement } of results) {
28+
if (replacement) {
29+
map[name] = replacement
30+
}
31+
}
32+
replacements.value = map
33+
}
34+
35+
/**
36+
* Fetch module replacement suggestions for a set of dependencies.
37+
* Returns a reactive map of dependency name to ModuleReplacement.
38+
*/
39+
export function useReplacementDependencies(
40+
dependencies: MaybeRefOrGetter<Record<string, string> | undefined>,
41+
) {
42+
const replacements = shallowRef<Record<string, ModuleReplacement>>({})
43+
44+
if (import.meta.client) {
45+
watch(
46+
() => toValue(dependencies),
47+
deps => {
48+
fetchReplacements(deps, replacements).catch(() => {})
49+
},
50+
{ immediate: true },
51+
)
52+
}
53+
54+
return replacements
55+
}

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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 = {
6+
type: 'simple',
7+
moduleName: 'is-even',
8+
replacement: 'Use (n % 2) === 0',
9+
category: 'micro-utilities',
10+
} satisfies ModuleReplacement
11+
12+
const NATIVE_REPLACEMENT = {
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+
} satisfies ModuleReplacement
20+
21+
/**
22+
* Helper to test useReplacementDependencies by wrapping it in a component.
23+
* Needed because the composable uses watch + import.meta.client.
24+
*/
25+
async function mountWithDeps(deps: Record<string, string> | undefined) {
26+
const captured = ref<Record<string, ModuleReplacement>>({})
27+
28+
const WrapperComponent = defineComponent({
29+
setup() {
30+
const replacements = useReplacementDependencies(() => deps)
31+
32+
watchEffect(() => {
33+
captured.value = { ...replacements.value }
34+
})
35+
36+
return () => h('div')
37+
},
38+
})
39+
40+
await mountSuspended(WrapperComponent)
41+
42+
return captured
43+
}
44+
45+
describe('useReplacementDependencies', () => {
46+
it('returns replacements for dependencies that have them', async () => {
47+
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
48+
registerEndpoint('/api/replacements/picoquery', () => null)
49+
50+
const replacements = await mountWithDeps({
51+
'is-even': '^1.0.0',
52+
'picoquery': '^1.0.0',
53+
})
54+
55+
await vi.waitFor(() => {
56+
expect(replacements.value['is-even']).toBeDefined()
57+
})
58+
59+
expect(replacements.value['is-even']?.type).toBe('simple')
60+
expect(replacements.value['picoquery']).toBeUndefined()
61+
})
62+
63+
it('returns empty object for undefined dependencies', async () => {
64+
const replacements = await mountWithDeps(undefined)
65+
66+
await vi.waitFor(() => {
67+
expect(replacements.value).toEqual({})
68+
})
69+
})
70+
71+
it('returns empty object for empty dependencies', async () => {
72+
const replacements = await mountWithDeps({})
73+
74+
await vi.waitFor(() => {
75+
expect(replacements.value).toEqual({})
76+
})
77+
})
78+
79+
it('handles multiple dependencies with replacements', async () => {
80+
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
81+
registerEndpoint('/api/replacements/array-includes', () => NATIVE_REPLACEMENT)
82+
registerEndpoint('/api/replacements/picoquery', () => null)
83+
84+
const replacements = await mountWithDeps({
85+
'is-even': '^1.0.0',
86+
'array-includes': '^3.0.0',
87+
'picoquery': '^1.0.0',
88+
})
89+
90+
await vi.waitFor(() => {
91+
expect(Object.keys(replacements.value)).toHaveLength(2)
92+
})
93+
94+
expect(replacements.value['is-even']?.type).toBe('simple')
95+
expect(replacements.value['array-includes']?.type).toBe('native')
96+
expect(replacements.value['picoquery']).toBeUndefined()
97+
})
98+
99+
it('handles fetch errors gracefully', async () => {
100+
registerEndpoint('/api/replacements/failing-package', () => {
101+
throw new Error('Network error')
102+
})
103+
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
104+
105+
const replacements = await mountWithDeps({
106+
'failing-package': '^1.0.0',
107+
'is-even': '^1.0.0',
108+
})
109+
110+
await vi.waitFor(() => {
111+
expect(replacements.value['is-even']).toBeDefined()
112+
})
113+
114+
expect(replacements.value['failing-package']).toBeUndefined()
115+
expect(replacements.value['is-even']?.type).toBe('simple')
116+
})
117+
})

0 commit comments

Comments
 (0)