Skip to content

Commit 1eab72f

Browse files
committed
feat: highlight outdated dependencies
1 parent c7ff8c3 commit 1eab72f

5 files changed

Lines changed: 274 additions & 43 deletions

File tree

app/components/PackageDependencies.vue

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<script setup lang="ts">
2+
import { useOutdatedDependencies, getOutdatedTooltip } from '~/composables/useNpmRegistry'
3+
import type { OutdatedDependencyInfo } from '~/composables/useNpmRegistry'
4+
25
const props = defineProps<{
36
packageName: string
47
dependencies?: Record<string, string>
@@ -7,6 +10,23 @@ const props = defineProps<{
710
optionalDependencies?: Record<string, string>
811
}>()
912
13+
// Fetch outdated info for dependencies
14+
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
15+
16+
/**
17+
* Get CSS class for a dependency version based on outdated status
18+
*/
19+
function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
20+
if (!info) return 'text-fg-subtle'
21+
22+
// Red for major versions behind
23+
if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
24+
// Orange for minor versions behind
25+
if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
26+
// Yellow for patch versions behind
27+
return 'text-yellow-500 cursor-help'
28+
}
29+
1030
// Expanded state for each section
1131
const depsExpanded = ref(false)
1232
const peerDepsExpanded = ref(false)
@@ -62,8 +82,9 @@ const sortedOptionalDependencies = computed(() => {
6282
{{ dep }}
6383
</NuxtLink>
6484
<span
65-
class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate"
66-
:title="version"
85+
class="font-mono text-xs text-right truncate"
86+
:class="getVersionClass(outdatedDeps[dep])"
87+
:title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep]) : version"
6788
>
6889
{{ version }}
6990
</span>

app/components/PackageMetricsBadges.vue

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ const moduleFormatTooltip = computed(() => {
5757
5858
const hasTypes = computed(() => {
5959
if (!analysis.value) return false
60-
return analysis.value.types.kind === 'included' || analysis.value.types.kind === '@types'
60+
return analysis.value.types?.kind === 'included' || analysis.value.types?.kind === '@types'
6161
})
6262
6363
const typesTooltip = computed(() => {
6464
if (!analysis.value) return ''
65-
switch (analysis.value.types.kind) {
65+
switch (analysis.value.types?.kind) {
6666
case 'included':
6767
return 'TypeScript types included'
6868
case '@types':
@@ -82,13 +82,7 @@ const typesHref = computed(() => {
8282
</script>
8383

8484
<template>
85-
<!-- Loading skeleton -->
86-
<div v-if="status === 'pending'" class="flex items-center gap-1.5">
87-
<span class="skeleton w-8 h-5 rounded" />
88-
<span class="skeleton w-12 h-5 rounded" />
89-
</div>
90-
91-
<ul v-else-if="analysis" class="flex items-center gap-1.5 list-none m-0 p-0">
85+
<ul v-if="analysis" class="flex items-center gap-1.5 list-none m-0 p-0">
9286
<!-- TypeScript types -->
9387
<li v-if="hasTypes">
9488
<component

app/components/PackageVersions.vue

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getPrereleaseChannel,
99
parseVersion,
1010
} from '~/utils/versions'
11+
import { fetchAllPackageVersions } from '~/composables/useNpmRegistry'
1112
1213
const props = defineProps<{
1314
packageName: string
@@ -107,18 +108,12 @@ const otherMajorGroups = ref<
107108
>([])
108109
const otherVersionsLoading = ref(false)
109110
110-
// Cached full version list
111+
// Cached full version list (local to component instance)
111112
const allVersionsCache = ref<PackageVersionInfo[] | null>(null)
112113
const loadingVersions = ref(false)
113114
const hasLoadedAll = ref(false)
114115
115-
// npm registry packument type (simplified)
116-
interface NpmPackument {
117-
versions: Record<string, unknown>
118-
time: Record<string, string>
119-
}
120-
121-
// Load all versions directly from npm registry
116+
// Load all versions using shared function
122117
async function loadAllVersions(): Promise<PackageVersionInfo[]> {
123118
if (allVersionsCache.value) return allVersionsCache.value
124119
@@ -136,23 +131,7 @@ async function loadAllVersions(): Promise<PackageVersionInfo[]> {
136131
137132
loadingVersions.value = true
138133
try {
139-
// Fetch directly from npm registry
140-
const encodedName = props.packageName.startsWith('@')
141-
? `@${encodeURIComponent(props.packageName.slice(1))}`
142-
: encodeURIComponent(props.packageName)
143-
144-
const data = await $fetch<NpmPackument>(`https://registry.npmjs.org/${encodedName}`)
145-
146-
// Convert to our format
147-
const versions: PackageVersionInfo[] = Object.keys(data.versions)
148-
.filter(v => data.time[v])
149-
.map(version => ({
150-
version,
151-
time: data.time[version],
152-
hasProvenance: false,
153-
}))
154-
.sort((a, b) => compareVersions(b.version, a.version))
155-
134+
const versions = await fetchAllPackageVersions(props.packageName)
156135
allVersionsCache.value = versions
157136
hasLoadedAll.value = true
158137
return versions

app/composables/useNpmRegistry.ts

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,40 @@ import type {
66
NpmSearchResult,
77
NpmDownloadCount,
88
NpmPerson,
9+
PackageVersionInfo,
910
} from '#shared/types'
11+
import type { ReleaseType } from 'semver'
12+
import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver'
13+
import { compareVersions } from '~/utils/versions'
1014

1115
const NPM_REGISTRY = 'https://registry.npmjs.org'
1216
const NPM_API = 'https://api.npmjs.org'
1317

18+
// Cache for packument fetches to avoid duplicate requests across components
19+
const packumentCache = new Map<string, Promise<Packument | null>>()
20+
21+
/**
22+
* Fetch a package's full packument data.
23+
* Uses caching to avoid duplicate requests.
24+
*/
1425
async function fetchNpmPackage(name: string): Promise<Packument> {
1526
const encodedName = encodePackageName(name)
1627
return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`)
1728
}
1829

30+
/**
31+
* Fetch a package's packument with caching (returns null on error).
32+
* This is useful for batch operations where some packages might not exist.
33+
*/
34+
async function fetchCachedPackument(name: string): Promise<Packument | null> {
35+
const cached = packumentCache.get(name)
36+
if (cached) return cached
37+
38+
const promise = fetchNpmPackage(name).catch(() => null)
39+
packumentCache.set(name, promise)
40+
return promise
41+
}
42+
1943
async function searchNpmPackages(
2044
query: string,
2145
options: {
@@ -45,7 +69,11 @@ async function fetchNpmDownloads(
4569
return await $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/${period}/${encodedName}`)
4670
}
4771

48-
function encodePackageName(name: string): string {
72+
/**
73+
* Encode a package name for use in npm registry URLs.
74+
* Handles scoped packages (e.g., @scope/name -> @scope%2Fname).
75+
*/
76+
export function encodePackageName(name: string): string {
4977
if (name.startsWith('@')) {
5078
return `@${encodeURIComponent(name.slice(1))}`
5179
}
@@ -326,3 +354,198 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
326354
{ default: () => emptySearchResponse },
327355
)
328356
}
357+
358+
// ============================================================================
359+
// Package Versions
360+
// ============================================================================
361+
362+
// Cache for full version lists
363+
const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>()
364+
365+
/**
366+
* Fetch all versions of a package from the npm registry.
367+
* Returns version info sorted by version (newest first).
368+
* Results are cached to avoid duplicate requests.
369+
*/
370+
export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> {
371+
const cached = allVersionsCache.get(packageName)
372+
if (cached) return cached
373+
374+
const promise = (async () => {
375+
const encodedName = encodePackageName(packageName)
376+
const data = await $fetch<{ versions: Record<string, unknown>; time: Record<string, string> }>(
377+
`${NPM_REGISTRY}/${encodedName}`,
378+
)
379+
380+
return Object.keys(data.versions)
381+
.filter(v => data.time[v])
382+
.map(version => ({
383+
version,
384+
time: data.time[version],
385+
hasProvenance: false, // Would need to check dist.attestations for each version
386+
}))
387+
.sort((a, b) => compareVersions(b.version, a.version))
388+
})()
389+
390+
allVersionsCache.set(packageName, promise)
391+
return promise
392+
}
393+
394+
// ============================================================================
395+
// Outdated Dependencies
396+
// ============================================================================
397+
398+
/** Information about an outdated dependency */
399+
export interface OutdatedDependencyInfo {
400+
/** The resolved version that satisfies the constraint */
401+
resolved: string
402+
/** The latest available version */
403+
latest: string
404+
/** How many major versions behind */
405+
majorsBehind: number
406+
/** How many minor versions behind (when same major) */
407+
minorsBehind: number
408+
/** The type of version difference */
409+
diffType: ReleaseType | null
410+
}
411+
412+
/**
413+
* Check if a version constraint explicitly includes a prerelease tag.
414+
* e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
415+
*/
416+
function constraintIncludesPrerelease(constraint: string): boolean {
417+
return (
418+
/-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) ||
419+
/-\d/.test(constraint)
420+
)
421+
}
422+
423+
/**
424+
* Check if a constraint is a non-semver value (git URL, file path, etc.)
425+
*/
426+
function isNonSemverConstraint(constraint: string): boolean {
427+
return (
428+
constraint.startsWith('git') ||
429+
constraint.startsWith('http') ||
430+
constraint.startsWith('file:') ||
431+
constraint.startsWith('npm:') ||
432+
constraint.startsWith('link:') ||
433+
constraint.startsWith('workspace:') ||
434+
constraint.includes('/')
435+
)
436+
}
437+
438+
/**
439+
* Check if a dependency is outdated.
440+
* Returns null if up-to-date or if we can't determine.
441+
*
442+
* A dependency is only considered "outdated" if the resolved version
443+
* is older than the latest version. If the resolved version is newer
444+
* (e.g., using ^2.0.0-rc when latest is 1.x), it's not outdated.
445+
*/
446+
async function checkDependencyOutdated(
447+
packageName: string,
448+
constraint: string,
449+
): Promise<OutdatedDependencyInfo | null> {
450+
if (isNonSemverConstraint(constraint)) {
451+
return null
452+
}
453+
454+
const packument = await fetchCachedPackument(packageName)
455+
if (!packument) return null
456+
457+
let versions = Object.keys(packument.versions)
458+
const includesPrerelease = constraintIncludesPrerelease(constraint)
459+
460+
if (!includesPrerelease) {
461+
versions = versions.filter(v => !prerelease(v))
462+
}
463+
464+
const resolved = maxSatisfying(versions, constraint)
465+
if (!resolved) return null
466+
467+
const latestTag = packument['dist-tags']?.latest
468+
if (!latestTag || resolved === latestTag) return null
469+
470+
// If resolved version is newer than latest, not outdated
471+
// (e.g., using ^2.0.0-rc when latest is 1.x)
472+
if (gt(resolved, latestTag)) {
473+
return null
474+
}
475+
476+
const diffType = diff(resolved, latestTag)
477+
const majorsBehind = major(latestTag) - major(resolved)
478+
const minorsBehind = majorsBehind === 0 ? minor(latestTag) - minor(resolved) : 0
479+
480+
return {
481+
resolved,
482+
latest: latestTag,
483+
majorsBehind,
484+
minorsBehind,
485+
diffType,
486+
}
487+
}
488+
489+
/**
490+
* Composable to check for outdated dependencies.
491+
* Returns a reactive map of dependency name to outdated info.
492+
*/
493+
export function useOutdatedDependencies(
494+
dependencies: MaybeRefOrGetter<Record<string, string> | undefined>,
495+
) {
496+
const outdated = ref<Record<string, OutdatedDependencyInfo>>({})
497+
498+
async function fetchOutdatedInfo(deps: Record<string, string> | undefined) {
499+
if (!deps || Object.keys(deps).length === 0) {
500+
outdated.value = {}
501+
return
502+
}
503+
504+
const results: Record<string, OutdatedDependencyInfo> = {}
505+
const entries = Object.entries(deps)
506+
const batchSize = 5
507+
508+
for (let i = 0; i < entries.length; i += batchSize) {
509+
const batch = entries.slice(i, i + batchSize)
510+
const batchResults = await Promise.all(
511+
batch.map(async ([name, constraint]) => {
512+
const info = await checkDependencyOutdated(name, constraint)
513+
return [name, info] as const
514+
}),
515+
)
516+
517+
for (const [name, info] of batchResults) {
518+
if (info) {
519+
results[name] = info
520+
}
521+
}
522+
}
523+
524+
outdated.value = results
525+
}
526+
527+
watch(
528+
() => toValue(dependencies),
529+
deps => {
530+
fetchOutdatedInfo(deps)
531+
},
532+
{ immediate: true },
533+
)
534+
535+
return outdated
536+
}
537+
538+
/**
539+
* Get tooltip text for an outdated dependency
540+
*/
541+
export function getOutdatedTooltip(info: OutdatedDependencyInfo): string {
542+
if (info.majorsBehind > 0) {
543+
const s = info.majorsBehind === 1 ? '' : 's'
544+
return `${info.majorsBehind} major version${s} behind (latest: ${info.latest})`
545+
}
546+
if (info.minorsBehind > 0) {
547+
const s = info.minorsBehind === 1 ? '' : 's'
548+
return `${info.minorsBehind} minor version${s} behind (latest: ${info.latest})`
549+
}
550+
return `Patch update available (latest: ${info.latest})`
551+
}

0 commit comments

Comments
 (0)