11<script setup lang="ts">
2+ import type { NuxtError } from ' #app'
23import { SEVERITY_TEXT_COLORS , getHighestSeverity } from ' #shared/utils/severity'
34import { getOutdatedTooltip , getVersionClass } from ' ~/utils/npm/outdated-dependencies'
45
@@ -12,6 +13,7 @@ const props = defineProps<{
1213 peerDependencies? : Record <string , string >
1314 peerDependenciesMeta? : Record <string , { optional? : boolean }>
1415 optionalDependencies? : Record <string , string >
16+ bundledDependencies? : boolean | string []
1517}>()
1618
1719// Fetch outdated info for dependencies
@@ -38,11 +40,6 @@ function getDeprecatedDepInfo(depName: string) {
3840 return vulnTree .value .deprecatedPackages .find (p => p .name === depName && p .depth === ' direct' )
3941}
4042
41- // Get dependency size (only direct deps)
42- function getSizeDepInfo(depName : string ) {
43- return props .packageSize ?.dependencies ?.find (p => p .name === depName )?.size ?? null
44- }
45-
4643// Sort dependencies alphabetically
4744const sortedDependencies = computed (() => {
4845 if (! props .dependencies ) return []
@@ -72,6 +69,198 @@ const sortedOptionalDependencies = computed(() => {
7269 return Object .entries (props .optionalDependencies ).sort (([a ], [b ]) => a .localeCompare (b ))
7370})
7471
72+ // Fetch size information for dependencies that require it
73+ const { data : serverSizes, pending : sizesLoading } = await useAsyncData (
74+ ` sizes:${props .packageName }:${props .version } ` ,
75+ async (_app , { signal }) => {
76+ const entries = sortedDependencies .value
77+
78+ const results = await Promise .all (
79+ entries .map <
80+ Promise <
81+ { kind: ' success' ; packageSize : InstallSizeResult } | { kind: ' error' ; error : NuxtError }
82+ >
83+ > (async ([name , version ]) => {
84+ try {
85+ const { data : resolvedVersion, error } = await useResolvedVersion (name , version )
86+
87+ if (error .value || ! resolvedVersion .value ) return { kind: ' error' , error: error .value ! }
88+
89+ return {
90+ kind: ' success' ,
91+ packageSize: await $fetch <InstallSizeResult >(
92+ ` /api/registry/install-size/${name }/v/${encodeURIComponent (resolvedVersion .value )} ` ,
93+ { signal },
94+ ),
95+ }
96+ } catch (err ) {
97+ return { kind: ' error' , error: (err as Ref <NuxtError >)?.value }
98+ }
99+ }),
100+ )
101+
102+ return results .reduce (
103+ (acc , curr ) => {
104+ if (curr .kind === ' error' ) return acc
105+ acc [curr .packageSize .package ] = curr
106+ return acc
107+ },
108+ {} as Record <
109+ string ,
110+ { kind: ' success' ; packageSize: InstallSizeResult } | { kind: ' error' ; error: NuxtError }
111+ >,
112+ )
113+ },
114+ {
115+ watch: [sortedDependencies ],
116+ },
117+ )
118+
119+ // Minimum percentage to be shown as an individual slice
120+ const THRESHOLD_PERCENT = 10
121+
122+ type Sizereq = {
123+ info: InstallSizeResult
124+ bundled: boolean
125+ percent: number
126+ error: NuxtError | null
127+ }
128+
129+ // Process dependencies for size visualization
130+ const sortedSizereqDependecies = computed (() => {
131+ if (! props .packageSize ?.totalSize || ! props .packageSize .dependencies ) {
132+ return { visible: [], others: [], totalOthersSize: 0 , othersPercentage: 0 }
133+ }
134+
135+ const total = props .packageSize .totalSize
136+
137+ // 1. Map everything first, preserving the 'bundled' flag from the source
138+ const allMapped = props .packageSize .dependencies
139+ .map (depSize => {
140+ const bundled = ! sortedDependencies .value .some (([name ]) => name === depSize .name )
141+ const percent = props .packageSize ? (depSize .size / props .packageSize .totalSize ) * 100 : 0
142+ const serverData = serverSizes .value ?.[depSize .name ]
143+ const error = serverData ?.kind === ' error' ? serverData .error : null
144+ return {
145+ info:
146+ serverData ?.kind === ' success'
147+ ? {
148+ package: depSize .name ,
149+ totalSize: serverData .packageSize ?.totalSize ?? depSize .size ,
150+ selfSize: serverData .packageSize ?.selfSize ?? depSize .size ,
151+ }
152+ : {
153+ package: depSize .name ,
154+ totalSize: depSize .size ,
155+ selfSize: depSize .size ,
156+ },
157+ error ,
158+ bundled ,
159+ percent ,
160+ } as Sizereq
161+ })
162+ .sort ((a , b ) => {
163+ // Bundled first
164+ if (a .bundled !== b .bundled ) return a .bundled ? - 1 : 1
165+ return b .info .totalSize - a .info .totalSize
166+ })
167+
168+ const visible: Sizereq [] = []
169+ const others: Sizereq [] = []
170+
171+ for (const dep of allMapped ) {
172+ const percentage = (dep .info .totalSize / total ) * 100
173+ if (percentage >= THRESHOLD_PERCENT ) {
174+ visible .push ({ ... dep , percent: percentage })
175+ } else {
176+ others .push (dep )
177+ }
178+ }
179+
180+ const totalOthersSize = others .reduce ((acc , d ) => acc + d .info .totalSize , 0 )
181+ const othersPercentage = (totalOthersSize / total ) * 100
182+
183+ if (othersPercentage < THRESHOLD_PERCENT || others .length === 1 ) {
184+ visible .push (others [0 ]! )
185+ others .length = 0
186+ visible .sort ((a , b ) => b .info .totalSize - a .info .totalSize )
187+ }
188+
189+ return { visible , others , totalOthersSize , othersPercentage }
190+ })
191+
192+ const othersTooltip = computed (() => {
193+ const others = sortedSizereqDependecies .value .others
194+ if (others .length === 0 ) return ' '
195+
196+ const MAX_VISIBLE_IN_TOOLTIP = 0
197+ const visiblePart = others .slice (0 , MAX_VISIBLE_IN_TOOLTIP )
198+ const remainingCount = others .length - MAX_VISIBLE_IN_TOOLTIP
199+
200+ const lines = [
201+ bytesFormatter .format (sortedSizereqDependecies .value .totalOthersSize ),
202+ numberFormatter .value .format (sortedSizereqDependecies .value .othersPercentage ),
203+ ' ' ,
204+ ... visiblePart .flatMap (size => [size .info .package , getDepSizeTooltipText (size ), ' ' ]),
205+ ]
206+
207+ if (remainingCount > 0 ) {
208+ lines .push (t (' package.size_increase.deps' , { count: remainingCount }))
209+ }
210+
211+ return lines .join (' \n ' )
212+ })
213+
214+ const selfSizeWidth = computed (() => {
215+ if (! props .packageSize ?.selfSize || ! props .packageSize ?.totalSize ) return 0
216+ return (props .packageSize .selfSize / props .packageSize .totalSize ) * 100
217+ })
218+
219+ const remainingWidth = computed (() => {
220+ const total = props .packageSize ?.totalSize
221+ if (! total ) return 100
222+
223+ // Sum up everything we actually HAVE data for
224+ const self = props .packageSize .selfSize || 0
225+ const depsSum = [
226+ ... sortedSizereqDependecies .value .visible ,
227+ ... sortedSizereqDependecies .value .others ,
228+ ].reduce ((acc , d ) => acc + d .info .totalSize , 0 )
229+
230+ const width = ((total - (self + depsSum )) / total ) * 100
231+ return Math .max (0 , width )
232+ })
233+
234+ // Get dependency size tooltip
235+ function getDepSizeTooltip(dep : string ): string | undefined {
236+ const size = [
237+ ... sortedSizereqDependecies .value .visible ,
238+ ... sortedSizereqDependecies .value .others ,
239+ ].find (d => d .info .package === dep )
240+ return size && getDepSizeTooltipText (size )
241+ }
242+
243+ function getDepSizeTooltipText(size : Sizereq ): string {
244+ const packageSize = size ?.error ? undefined : size ?.info
245+ const percent = size ?.percent
246+ return [
247+ size ?.error ?.message ,
248+ percent && numberFormatter .value .format (percent ),
249+ packageSize &&
250+ packageSize ?.totalSize !== packageSize ?.selfSize &&
251+ t (' package.stats.size_tooltip.unpacked' , {
252+ size: bytesFormatter .format (packageSize .selfSize ! ),
253+ }),
254+ packageSize ?.totalSize &&
255+ t (' package.stats.size_tooltip.total' , {
256+ count: packageSize .dependencyCount ,
257+ size: bytesFormatter .format (packageSize .totalSize ),
258+ }),
259+ ]
260+ .filter (Boolean )
261+ .join (' \n ' )
262+ }
263+
75264// Get version tooltip
76265function getDepVersionTooltip(dep : string , version : string ) {
77266 const outdated = outdatedDeps .value [dep ]
@@ -128,6 +317,41 @@ const bytesFormatter = useBytesFormatter()
128317 )
129318 "
130319 >
320+ <div class =" gap-0.5 flex flex-row h-6 w-full bg-fg-muted/10 overflow-hidden rounded-md" >
321+ <TooltipApp
322+ v-if =" selfSizeWidth > 0"
323+ :text ="
324+ t('package.stats.size_tooltip.unpacked', {
325+ size: bytesFormatter.format(props.packageSize?.selfSize || 0),
326+ })
327+ "
328+ class =" h-full bg-blue-500"
329+ :style =" { width: selfSizeWidth + '%' }"
330+ />
331+
332+ <template v-for =" dep in sortedSizereqDependecies .visible " :key =" dep .info .package " >
333+ <TooltipApp
334+ :text =" `${dep.info.package}\n${getDepSizeTooltip(dep.info.package)}`"
335+ class =" h-full"
336+ :class =" dep.bundled ? 'bg-blue-500' : 'bg-fg'"
337+ :style =" { width: dep.percent + '%' }"
338+ />
339+ </template >
340+
341+ <TooltipApp
342+ v-if =" sortedSizereqDependecies.others.length > 0"
343+ :text =" othersTooltip"
344+ class =" h-full bg-fg flex items-center justify-center"
345+ :style =" { width: sortedSizereqDependecies.othersPercentage + '%' }"
346+ >
347+ <span class =" i-lucide:layers-2 w-3 h-3 text-bg" aria-hidden =" true" />
348+ </TooltipApp >
349+
350+ <div
351+ v-if =" remainingWidth > 0"
352+ class =" h-full bg-bg-elevated animate-skeleton-pulse flex-1"
353+ />
354+ </div >
131355 <ul class =" space-y-1 list-none m-0" :aria-label =" $t('package.dependencies.list_label')" >
132356 <li
133357 v-for =" [dep, version] in visibleDeps"
@@ -197,25 +421,23 @@ const bytesFormatter = useBytesFormatter()
197421 {{ version }}
198422 </LinkBase >
199423 <TooltipApp
200- v-if =" getSizeDepInfo (dep)"
424+ v-if =" getDepSizeTooltip (dep)"
201425 class =" shrink-0"
202426 :class =" getVersionClass(undefined)"
203- :text ="
204- $t('package.stats.size_tooltip.unpacked', {
205- size: bytesFormatter.format(getSizeDepInfo(dep)!),
206- })
207- "
427+ :text =" getDepSizeTooltip(dep)"
208428 >
209429 <button
210430 type =" button"
211- class =" inline-flex items-center justify-center p-2 -m-2"
212- :aria-label ="
213- $t('package.stats.size_tooltip.unpacked', {
214- size: bytesFormatter.format(getSizeDepInfo(dep)!),
215- })
216- "
431+ class =" inline-flex items-center justify-center p-2 -m-2 outline-none"
432+ :aria-label =" getDepSizeTooltip(dep)"
217433 >
218- <span class =" i-lucide:info w-3 h-3" aria-hidden =" true" />
434+ <span
435+ class =" i-lucide:info w-3 h-3 opacity-50 transition-opacity hover:opacity-100"
436+ :class =" {
437+ 'i-svg-spinners:ring-resize': sizesLoading && !serverSizes?.[dep],
438+ }"
439+ aria-hidden =" true"
440+ />
219441 </button >
220442 </TooltipApp >
221443 <span v-if =" outdatedDeps[dep]" class =" sr-only" >
0 commit comments