Skip to content

Commit 16811bc

Browse files
authored
Merge branch 'main' into dwells-test-server-utils
2 parents 48bf88e + 5b618d8 commit 16811bc

21 files changed

Lines changed: 951 additions & 431 deletions

app/components/PackageDependencies.vue

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ const props = defineProps<{
44
dependencies?: Record<string, string>
55
peerDependencies?: Record<string, string>
66
peerDependenciesMeta?: Record<string, { optional?: boolean }>
7+
optionalDependencies?: Record<string, string>
78
}>()
89
910
// Expanded state for each section
1011
const depsExpanded = ref(false)
1112
const peerDepsExpanded = ref(false)
13+
const optionalDepsExpanded = ref(false)
1214
1315
// Sort dependencies alphabetically
1416
const sortedDependencies = computed(() => {
@@ -32,27 +34,21 @@ const sortedPeerDependencies = computed(() => {
3234
return a.name.localeCompare(b.name)
3335
})
3436
})
37+
38+
// Sort optional dependencies alphabetically
39+
const sortedOptionalDependencies = computed(() => {
40+
if (!props.optionalDependencies) return []
41+
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
42+
})
3543
</script>
3644

3745
<template>
3846
<div class="space-y-8">
3947
<!-- Dependencies -->
4048
<section v-if="sortedDependencies.length > 0" aria-labelledby="dependencies-heading">
41-
<div class="flex items-center justify-between mb-3">
42-
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
43-
Dependencies ({{ sortedDependencies.length }})
44-
</h2>
45-
<a
46-
:href="`https://npmgraph.js.org/?q=${packageName}`"
47-
target="_blank"
48-
rel="noopener noreferrer"
49-
class="link-subtle text-fg-subtle"
50-
aria-label="View dependency graph"
51-
title="View dependency graph"
52-
>
53-
<span class="text-xs uppercase tracking-wider"> Graph </span>
54-
</a>
55-
</div>
49+
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
50+
Dependencies ({{ sortedDependencies.length }})
51+
</h2>
5652
<ul class="space-y-1 list-none m-0 p-0" aria-label="Package dependencies">
5753
<li
5854
v-for="[dep, version] in sortedDependencies.slice(0, depsExpanded ? undefined : 10)"
@@ -129,5 +125,49 @@ const sortedPeerDependencies = computed(() => {
129125
show all {{ sortedPeerDependencies.length }} peer deps
130126
</button>
131127
</section>
128+
129+
<!-- Optional Dependencies -->
130+
<section
131+
v-if="sortedOptionalDependencies.length > 0"
132+
aria-labelledby="optional-dependencies-heading"
133+
>
134+
<h2
135+
id="optional-dependencies-heading"
136+
class="text-xs text-fg-subtle uppercase tracking-wider mb-3"
137+
>
138+
Optional Dependencies ({{ sortedOptionalDependencies.length }})
139+
</h2>
140+
<ul class="space-y-1 list-none m-0 p-0" aria-label="Package optional dependencies">
141+
<li
142+
v-for="[dep, version] in sortedOptionalDependencies.slice(
143+
0,
144+
optionalDepsExpanded ? undefined : 10,
145+
)"
146+
:key="dep"
147+
class="flex items-center justify-between py-1 text-sm gap-2"
148+
>
149+
<NuxtLink
150+
:to="{ name: 'package', params: { package: dep.split('/') } }"
151+
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0"
152+
>
153+
{{ dep }}
154+
</NuxtLink>
155+
<span
156+
class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate"
157+
:title="version"
158+
>
159+
{{ version }}
160+
</span>
161+
</li>
162+
</ul>
163+
<button
164+
v-if="sortedOptionalDependencies.length > 10 && !optionalDepsExpanded"
165+
type="button"
166+
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
167+
@click="optionalDepsExpanded = true"
168+
>
169+
show all {{ sortedOptionalDependencies.length }} optional deps
170+
</button>
171+
</section>
132172
</div>
133173
</template>
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<script setup lang="ts">
2+
import type {
3+
OsvQueryResponse,
4+
OsvVulnerability,
5+
OsvSeverityLevel,
6+
VulnerabilitySummary,
7+
} from '#shared/types'
8+
9+
const props = defineProps<{
10+
packageName: string
11+
version: string
12+
}>()
13+
14+
const { data: vulnData, status } = useLazyAsyncData(
15+
`osv-${props.packageName}@${props.version}`,
16+
async () => {
17+
const response = await $fetch<OsvQueryResponse>('https://api.osv.dev/v1/query', {
18+
method: 'POST',
19+
body: {
20+
package: {
21+
name: props.packageName,
22+
ecosystem: 'npm',
23+
},
24+
version: props.version,
25+
},
26+
})
27+
28+
const vulns = response.vulns || []
29+
const vulnerabilities = vulns.map(toVulnerabilitySummary)
30+
31+
// Sort by severity (critical first)
32+
const severityOrder: Record<OsvSeverityLevel, number> = {
33+
critical: 0,
34+
high: 1,
35+
moderate: 2,
36+
low: 3,
37+
unknown: 4,
38+
}
39+
vulnerabilities.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
40+
41+
// Count by severity
42+
const counts = { total: vulnerabilities.length, critical: 0, high: 0, moderate: 0, low: 0 }
43+
for (const v of vulnerabilities) {
44+
if (v.severity === 'critical') counts.critical++
45+
else if (v.severity === 'high') counts.high++
46+
else if (v.severity === 'moderate') counts.moderate++
47+
else if (v.severity === 'low') counts.low++
48+
}
49+
50+
return { vulnerabilities, counts }
51+
},
52+
{
53+
default: () => ({
54+
vulnerabilities: [] as VulnerabilitySummary[],
55+
counts: { total: 0, critical: 0, high: 0, moderate: 0, low: 0 },
56+
}),
57+
},
58+
)
59+
60+
function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
61+
const dbSeverity = vuln.database_specific?.severity?.toLowerCase()
62+
if (dbSeverity) {
63+
if (dbSeverity === 'critical') return 'critical'
64+
if (dbSeverity === 'high') return 'high'
65+
if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate'
66+
if (dbSeverity === 'low') return 'low'
67+
}
68+
69+
const severityEntry = vuln.severity?.[0]
70+
if (severityEntry?.score) {
71+
const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/)
72+
if (match?.[1]) {
73+
const score = parseFloat(match[1])
74+
if (score >= 9.0) return 'critical'
75+
if (score >= 7.0) return 'high'
76+
if (score >= 4.0) return 'moderate'
77+
if (score > 0) return 'low'
78+
}
79+
}
80+
81+
return 'unknown'
82+
}
83+
84+
function getVulnerabilityUrl(vuln: OsvVulnerability): string {
85+
if (vuln.id.startsWith('GHSA-')) {
86+
return `https://github.com/advisories/${vuln.id}`
87+
}
88+
const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-'))
89+
if (cveAlias) {
90+
return `https://nvd.nist.gov/vuln/detail/${cveAlias}`
91+
}
92+
return `https://osv.dev/vulnerability/${vuln.id}`
93+
}
94+
95+
function toVulnerabilitySummary(vuln: OsvVulnerability): VulnerabilitySummary {
96+
return {
97+
id: vuln.id,
98+
summary: vuln.summary || 'No description available',
99+
severity: getSeverityLevel(vuln),
100+
aliases: vuln.aliases || [],
101+
url: getVulnerabilityUrl(vuln),
102+
}
103+
}
104+
105+
const hasVulnerabilities = computed(() => vulnData.value.counts.total > 0)
106+
107+
// Severity color classes for the banner
108+
const severityColors: Record<OsvSeverityLevel, string> = {
109+
critical: 'text-red-400 bg-red-500/10 border-red-500/30',
110+
high: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
111+
moderate: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30',
112+
low: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
113+
unknown: 'text-fg-muted bg-bg-subtle border-border',
114+
}
115+
116+
// Severity badge styles - greyscale theme matching the design system
117+
const severityBadgeColors: Record<OsvSeverityLevel, string> = {
118+
critical: 'bg-bg-muted border border-border text-fg',
119+
high: 'bg-bg-muted border border-border text-fg-muted',
120+
moderate: 'bg-bg-muted border border-border text-fg-muted',
121+
low: 'bg-bg-muted border border-border text-fg-subtle',
122+
unknown: 'bg-bg-muted border border-border text-fg-subtle',
123+
}
124+
125+
// Expand/collapse state
126+
const isExpanded = ref(false)
127+
128+
// Get highest severity for banner color
129+
const highestSeverity = computed<OsvSeverityLevel>(() => {
130+
const counts = vulnData.value.counts
131+
if (counts.critical > 0) return 'critical'
132+
if (counts.high > 0) return 'high'
133+
if (counts.moderate > 0) return 'moderate'
134+
if (counts.low > 0) return 'low'
135+
return 'unknown'
136+
})
137+
138+
// Summary text for collapsed view
139+
const summaryText = computed(() => {
140+
const counts = vulnData.value.counts
141+
const parts: string[] = []
142+
if (counts.critical > 0) parts.push(`${counts.critical} critical`)
143+
if (counts.high > 0) parts.push(`${counts.high} high`)
144+
if (counts.moderate > 0) parts.push(`${counts.moderate} moderate`)
145+
if (counts.low > 0) parts.push(`${counts.low} low`)
146+
return parts.join(', ')
147+
})
148+
</script>
149+
150+
<template>
151+
<div v-if="status === 'success' && hasVulnerabilities" class="mb-6">
152+
<!-- Collapsible vulnerability banner -->
153+
<div
154+
role="alert"
155+
class="rounded-lg border overflow-hidden"
156+
:class="severityColors[highestSeverity]"
157+
>
158+
<!-- Header (always visible, clickable to expand) -->
159+
<button
160+
type="button"
161+
class="w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors duration-200 hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-fg/50"
162+
:aria-expanded="isExpanded"
163+
aria-controls="vulnerability-details"
164+
@click="isExpanded = !isExpanded"
165+
>
166+
<div class="flex items-center gap-2 min-w-0">
167+
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
168+
<span class="font-mono text-sm font-medium truncate">
169+
{{ vulnData.counts.total }}
170+
{{ vulnData.counts.total === 1 ? 'vulnerability' : 'vulnerabilities' }} found
171+
</span>
172+
</div>
173+
<div class="flex items-center gap-2 shrink-0">
174+
<span class="text-xs opacity-80 hidden sm:inline">{{ summaryText }}</span>
175+
<span
176+
class="i-carbon-chevron-down w-4 h-4 transition-transform duration-200"
177+
:class="{ 'rotate-180': isExpanded }"
178+
aria-hidden="true"
179+
/>
180+
</div>
181+
</button>
182+
183+
<!-- Expandable details - neutral background for better contrast -->
184+
<div
185+
v-show="isExpanded"
186+
id="vulnerability-details"
187+
class="border-t border-border bg-bg-subtle"
188+
>
189+
<ul class="divide-y divide-border list-none m-0 p-0">
190+
<li
191+
v-for="vuln in vulnData.vulnerabilities"
192+
:key="vuln.id"
193+
class="px-4 py-3 hover:bg-bg-muted transition-colors duration-200"
194+
>
195+
<div class="flex items-start justify-between gap-3">
196+
<div class="min-w-0 flex-1">
197+
<div class="flex items-center gap-2 mb-1">
198+
<a
199+
:href="vuln.url"
200+
target="_blank"
201+
rel="noopener noreferrer"
202+
class="font-mono text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
203+
>
204+
{{ vuln.id }}
205+
</a>
206+
<span
207+
class="px-2 py-0.5 text-xs font-mono rounded"
208+
:class="severityBadgeColors[vuln.severity]"
209+
>
210+
{{ vuln.severity }}
211+
</span>
212+
</div>
213+
<p class="text-sm text-fg-muted line-clamp-2 m-0">
214+
{{ vuln.summary }}
215+
</p>
216+
<div v-if="vuln.aliases.length > 0" class="mt-1">
217+
<span
218+
v-for="alias in vuln.aliases.slice(0, 2)"
219+
:key="alias"
220+
class="text-xs text-fg-subtle mr-2"
221+
>
222+
{{ alias }}
223+
</span>
224+
</div>
225+
</div>
226+
<a
227+
:href="vuln.url"
228+
target="_blank"
229+
rel="noopener noreferrer"
230+
class="shrink-0 p-1.5 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
231+
aria-label="View vulnerability details"
232+
>
233+
<span class="i-carbon-launch w-3.5 h-3.5" aria-hidden="true" />
234+
</a>
235+
</div>
236+
</li>
237+
</ul>
238+
</div>
239+
</div>
240+
</div>
241+
</template>

0 commit comments

Comments
 (0)