diff --git a/app/components/Package/Dependencies.vue b/app/components/Package/Dependencies.vue
index 74427ceb2..e0f12c46a 100644
--- a/app/components/Package/Dependencies.vue
+++ b/app/components/Package/Dependencies.vue
@@ -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
@@ -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,
@@ -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()
@@ -104,6 +127,14 @@ const numberFormatter = useNumberFormatter()
>
+
+
+
{{ version }}
diff --git a/app/composables/npm/useReplacementDependencies.ts b/app/composables/npm/useReplacementDependencies.ts
new file mode 100644
index 000000000..e0aa85293
--- /dev/null
+++ b/app/composables/npm/useReplacementDependencies.ts
@@ -0,0 +1,63 @@
+import type { ModuleReplacement } from 'module-replacements'
+
+async function fetchReplacements(
+ deps: Record,
+): Promise> {
+ const names = Object.keys(deps)
+
+ const results = await Promise.all(
+ names.map(async name => {
+ try {
+ const replacement = await $fetch(`/api/replacements/${name}`)
+ return { name, replacement }
+ } catch {
+ return { name, replacement: null }
+ }
+ }),
+ )
+
+ const map: Record = {}
+ 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 | undefined>,
+) {
+ const replacements = shallowRef>({})
+ 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
+}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index e483a2f77..1c44e5c9b 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -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})",
diff --git a/i18n/schema.json b/i18n/schema.json
index 5604780cd..05d22e2fe 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -954,6 +954,9 @@
},
"outdated_patch": {
"type": "string"
+ },
+ "has_replacement": {
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index bb8076de4..d1278dc75 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -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})",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index f9b9ccfc2..8f70103ce 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -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})",
diff --git a/test/nuxt/composables/use-replacement-dependencies.spec.ts b/test/nuxt/composables/use-replacement-dependencies.spec.ts
new file mode 100644
index 000000000..ec5f89bfb
--- /dev/null
+++ b/test/nuxt/composables/use-replacement-dependencies.spec.ts
@@ -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 | undefined) {
+ const captured = ref>({})
+
+ 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')
+ })
+})