Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'

const { t } = useI18n()

const props = defineProps<{
packageName: string
version: string
Expand All @@ -14,6 +16,9 @@ const props = defineProps<{
// Fetch outdated info for dependencies
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)

// Fetch replacement suggestions for dependencies
const replacementDeps = useReplacementDependencies(() => props.dependencies)

// Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree)
const { data: vulnTree } = useDependencyAnalysis(
() => props.packageName,
Expand Down Expand Up @@ -66,6 +71,24 @@ const sortedOptionalDependencies = computed(() => {
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
})

// Get version tooltip
function getDepVersionTooltip(dep: string, version: string) {
const outdated = outdatedDeps.value[dep]
if (outdated) return getOutdatedTooltip(outdated, t)
if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return version
if (replacementDeps.value[dep]) return t('package.dependencies.has_replacement')
return version
}

// Get version class
function getDepVersionClass(dep: string) {
const outdated = outdatedDeps.value[dep]
if (outdated) return getVersionClass(outdated)
if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return getVersionClass(undefined)
if (replacementDeps.value[dep]) return 'text-amber-700 dark:text-amber-500'
return getVersionClass(undefined)
}

const numberFormatter = useNumberFormatter()
</script>

Expand Down Expand Up @@ -104,6 +127,14 @@ const numberFormatter = useNumberFormatter()
>
<span class="i-carbon:warning-alt w-3 h-3" />
</TooltipApp>
<TooltipApp
v-if="replacementDeps[dep]"
class="shrink-0 p-2 -m-2 text-amber-700 dark:text-amber-500"
aria-hidden="true"
:text="$t('package.dependencies.has_replacement')"
>
<span class="i-carbon:idea w-3 h-3" />
</TooltipApp>
<LinkBase
v-if="getVulnerableDepInfo(dep)"
:to="packageRoute(dep, getVulnerableDepInfo(dep)!.version)"
Expand All @@ -126,8 +157,8 @@ const numberFormatter = useNumberFormatter()
<LinkBase
:to="packageRoute(dep, version)"
class="block truncate"
:class="getVersionClass(outdatedDeps[dep])"
:title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep], $t) : version"
:class="getDepVersionClass(dep)"
:title="getDepVersionTooltip(dep, version)"
>
{{ version }}
</LinkBase>
Expand Down
63 changes: 63 additions & 0 deletions app/composables/npm/useReplacementDependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ModuleReplacement } from 'module-replacements'

async function fetchReplacements(
deps: Record<string, string>,
): Promise<Record<string, ModuleReplacement>> {
const names = Object.keys(deps)

const results = await Promise.all(
names.map(async name => {
try {
const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`)
return { name, replacement }
} catch {
return { name, replacement: null }
}
}),
)

const map: Record<string, ModuleReplacement> = {}
for (const { name, replacement } of results) {
if (replacement) {
map[name] = replacement
}
}
return map
}

/**
* Fetch module replacement suggestions for a set of dependencies.
* Returns a reactive map of dependency name to ModuleReplacement.
*/
export function useReplacementDependencies(
dependencies: MaybeRefOrGetter<Record<string, string> | undefined>,
) {
const replacements = shallowRef<Record<string, ModuleReplacement>>({})
let generation = 0

if (import.meta.client) {
watch(
() => toValue(dependencies),
async deps => {
const currentGeneration = ++generation

if (!deps || Object.keys(deps).length === 0) {
replacements.value = {}
return
}

try {
const result = await fetchReplacements(deps)
if (currentGeneration === generation) {
replacements.value = result
}
} catch {
// catastrophic failure, just keep whatever we have
}
},
{ immediate: true },
)
}

return replacements
}
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})"
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,9 @@
},
"outdated_patch": {
"type": "string"
},
"has_replacement": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})"
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})"
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
Expand Down
113 changes: 113 additions & 0 deletions test/nuxt/composables/use-replacement-dependencies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import type { ModuleReplacement } from 'module-replacements'

const SIMPLE_REPLACEMENT: ModuleReplacement = {
type: 'simple',
moduleName: 'is-even',
replacement: 'Use (n % 2) === 0',
category: 'micro-utilities',
}

const NATIVE_REPLACEMENT: ModuleReplacement = {
type: 'native',
moduleName: 'array-includes',
nodeVersion: '6.0.0',
replacement: 'Array.prototype.includes',
mdnPath: 'Global_Objects/Array/includes',
category: 'native',
}

async function mountWithDeps(deps: Record<string, string> | undefined) {
const captured = ref<Record<string, ModuleReplacement>>({})

const WrapperComponent = defineComponent({
setup() {
const replacements = useReplacementDependencies(() => deps)

watchEffect(() => {
captured.value = { ...replacements.value }
})

return () => h('div')
},
})

await mountSuspended(WrapperComponent)

return captured
}

describe('useReplacementDependencies', () => {
it('returns replacements for dependencies that have them', async () => {
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
registerEndpoint('/api/replacements/picoquery', () => null)

const replacements = await mountWithDeps({
'is-even': '^1.0.0',
'picoquery': '^1.0.0',
})

await vi.waitFor(() => {
expect(replacements.value['is-even']).toBeDefined()
})

expect(replacements.value['is-even']?.type).toBe('simple')
expect(replacements.value['picoquery']).toBeUndefined()
})

it('returns empty object for undefined dependencies', async () => {
const replacements = await mountWithDeps(undefined)

await vi.waitFor(() => {
expect(replacements.value).toEqual({})
})
})

it('returns empty object for empty dependencies', async () => {
const replacements = await mountWithDeps({})

await vi.waitFor(() => {
expect(replacements.value).toEqual({})
})
})

it('handles multiple dependencies with replacements', async () => {
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
registerEndpoint('/api/replacements/array-includes', () => NATIVE_REPLACEMENT)
registerEndpoint('/api/replacements/picoquery', () => null)

const replacements = await mountWithDeps({
'is-even': '^1.0.0',
'array-includes': '^3.0.0',
'picoquery': '^1.0.0',
})

await vi.waitFor(() => {
expect(Object.keys(replacements.value)).toHaveLength(2)
})

expect(replacements.value['is-even']?.type).toBe('simple')
expect(replacements.value['array-includes']?.type).toBe('native')
expect(replacements.value['picoquery']).toBeUndefined()
})

it('handles fetch errors gracefully', async () => {
registerEndpoint('/api/replacements/failing-package', () => {
throw new Error('Network error')
})
registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)

const replacements = await mountWithDeps({
'failing-package': '^1.0.0',
'is-even': '^1.0.0',
})

await vi.waitFor(() => {
expect(replacements.value['is-even']).toBeDefined()
})

expect(replacements.value['failing-package']).toBeUndefined()
expect(replacements.value['is-even']?.type).toBe('simple')
})
})
Loading