diff --git a/app/components/Package/Dependencies.vue b/app/components/Package/Dependencies.vue index 2bddda193a..b512791b5b 100644 --- a/app/components/Package/Dependencies.vue +++ b/app/components/Package/Dependencies.vue @@ -7,10 +7,12 @@ const { t } = useI18n() const props = defineProps<{ packageName: string version: string + packageSize?: InstallSizeResult | undefined dependencies?: Record peerDependencies?: Record peerDependenciesMeta?: Record optionalDependencies?: Record + bundledDependencies?: boolean | string[] }>() // Fetch outdated info for dependencies @@ -121,6 +123,20 @@ const numberFormatter = useNumberFormatter() ) " > +
+
+
+ {{ $t('package.stats.install_size') }} +
+
+ +
  • +import type { InstallSizeResult } from '#shared/types/install-size' + +const props = withDefaults( + defineProps<{ + packageName: string + version: string + packageSize?: InstallSizeResult | undefined + dependencies?: Record + bundledDependencies?: boolean | string[] + height?: string + }>(), + { + height: 'h-6', + }, +) + +const { data: sizereqData } = usePackageDependencySizes( + props.packageName, + props.version, + props.packageSize?.dependencies, +) + +// Minimum percentage to be shown as an individual slice +const THRESHOLD_PERCENT = 2 + +type Sizereq = { + info: InstallSizeResult + bundled: boolean + percent: number +} + +// Process dependencies for size visualization +const sortedSizereqDependecies = computed(() => { + if (!props.packageSize?.totalSize || !props.packageSize.dependencies) { + return { visible: [], others: [], totalOthersSize: 0, othersPercentage: 0 } + } + + const allMapped = props.packageSize.dependencies.map(depSize => { + let bundled = false + switch (typeof props.bundledDependencies) { + case 'boolean': + bundled = props.bundledDependencies + break + case 'object': + bundled = props.bundledDependencies.some(name => name === depSize.name) + break + } + const percent = props.packageSize ? (depSize.size / props.packageSize.totalSize) * 100 : 0 + const serverData = sizereqData.value?.[depSize.name] + return { + info: + serverData?.kind === 'success' && serverData.packageSize + ? { + package: depSize.name, + version: depSize.version, + totalSize: serverData.packageSize.totalSize, + selfSize: serverData.packageSize.selfSize, + } + : { + package: depSize.name, + version: depSize.version, + totalSize: depSize.size, + selfSize: depSize.size, + }, + bundled, + percent, + } as Sizereq + }) + + const visible: Sizereq[] = [] + const others: Sizereq[] = [] + + for (const dep of allMapped) { + const percentage = (dep.info.selfSize / props.packageSize.totalSize) * 100 + if (percentage >= THRESHOLD_PERCENT) { + visible.push({ ...dep, percent: percentage }) + } else { + others.push(dep) + } + } + + const othersSelfSize = others.reduce((acc, d) => acc + d.info.selfSize, 0) + const othersPercentage = (othersSelfSize / props.packageSize.totalSize) * 100 + + return { visible, others, totalOthersSize: othersSelfSize, othersPercentage } +}) + +const selfSizeWidth = computed(() => { + if (!props.packageSize?.selfSize || !props.packageSize?.totalSize) return 0 + return (props.packageSize.selfSize / props.packageSize.totalSize) * 100 +}) + +const remainingWidth = computed(() => { + const total = props.packageSize?.totalSize + if (!total) return 100 + + const self = props.packageSize.selfSize || 0 + const depsSum = [ + ...sortedSizereqDependecies.value.visible, + ...sortedSizereqDependecies.value.others, + ].reduce((acc, d) => acc + d.info.selfSize, 0) + + const width = ((total - (self + depsSum)) / total) * 100 + return Math.max(0, width) +}) + + + diff --git a/app/components/Package/SizeCard.vue b/app/components/Package/SizeCard.vue new file mode 100644 index 0000000000..da9d1bc640 --- /dev/null +++ b/app/components/Package/SizeCard.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/components/Package/SizeList.vue b/app/components/Package/SizeList.vue new file mode 100644 index 0000000000..b158a29ca5 --- /dev/null +++ b/app/components/Package/SizeList.vue @@ -0,0 +1,121 @@ + + + diff --git a/app/components/Package/SizeTable.vue b/app/components/Package/SizeTable.vue new file mode 100644 index 0000000000..639abfa1da --- /dev/null +++ b/app/components/Package/SizeTable.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/composables/npm/usePackageSize.ts b/app/composables/npm/usePackageSize.ts new file mode 100644 index 0000000000..f2eefe887a --- /dev/null +++ b/app/composables/npm/usePackageSize.ts @@ -0,0 +1,133 @@ +import type { AsyncData, NuxtError } from '#app' +import type { InstallSizeResult } from '#shared/types/install-size' +import type { MaybeRefOrGetter, Ref } from 'vue' +import { toValue } from 'vue' +import { useAsyncData, useNumberFormatter, useBytesFormatter } from '#imports' + +export const usePackageSize = ( + packageName: MaybeRefOrGetter, + version: MaybeRefOrGetter, + options: any = {}, +) => { + return useAsyncData( + () => `install-size:${toValue(packageName)}:${toValue(version)}`, + () => + $fetch( + `/api/registry/install-size/${toValue(packageName)}/v/${toValue(version)}`, + ), + { + watch: [() => toValue(packageName), () => toValue(version)], + ...options, + }, + ) +} + +/** + * Fetches size information for all dependencies of a package. + */ +export function usePackageDependencySizes( + packageName: MaybeRefOrGetter, + version: MaybeRefOrGetter, + dependencies?: MaybeRefOrGetter, +): AsyncData< + | Record< + string, + { kind: 'success'; packageSize: InstallSizeResult } | { kind: 'error'; error: NuxtError } + > + | undefined, + NuxtError | undefined +> { + return useAsyncData( + `install-size-deps:${toValue(packageName)}:${toValue(version)}`, + async (_app, { signal }) => { + const entries = toValue(dependencies) ?? [] + + const results = await Promise.all( + entries.map< + Promise< + | { kind: 'success'; packageSize: InstallSizeResult } + | { kind: 'error'; error: NuxtError } + > + >(async ({ name, version: depVersion }) => { + try { + const { data: resolvedVersion, error } = await useResolvedVersion(name, depVersion) + + if (error.value || !resolvedVersion.value) return { kind: 'error', error: error.value! } + + return { + kind: 'success', + packageSize: await $fetch( + `/api/registry/install-size/${name}/v/${encodeURIComponent(resolvedVersion.value)}`, + { signal }, + ), + } + } catch (err) { + return { kind: 'error', error: (err as Ref)?.value } + } + }), + ) + + return results.reduce( + (acc, curr) => { + if (curr.kind === 'error') return acc + acc[curr.packageSize.package] = curr + return acc + }, + {} as Record< + string, + { kind: 'success'; packageSize: InstallSizeResult } | { kind: 'error'; error: NuxtError } + >, + ) + }, + { + server: false, + }, + ) +} + +/** + * Helper to generate dependency size tooltips. + */ +export function usePackageDependencySizeTooltip( + sizereqData: Ref | null>, + packageSize: MaybeRefOrGetter, + t: (key: string, params?: Record, count?: number) => string, +) { + const numberFormatter = useNumberFormatter() + const bytesFormatter = useBytesFormatter() + + function getTooltipText(dep: string): string | undefined { + const data = sizereqData.value?.[dep] + const total = toValue(packageSize)?.totalSize + + if (data?.kind === 'error') return data.error.message + + const info = data?.kind === 'success' ? data.packageSize : undefined + if (!info && !total) return undefined + + const percent = total && info ? (info.selfSize / total) * 100 : undefined + + return [ + percent && numberFormatter.value.format(percent), + info && + info?.totalSize !== info?.selfSize && + t('package.stats.size_tooltip.unpacked', { + size: bytesFormatter.format(info.selfSize!), + }), + info?.totalSize && + t('package.stats.size_tooltip.total', { + count: info.dependencyCount, + size: bytesFormatter.format(info.totalSize), + }), + ] + .filter(Boolean) + .join('\n') + } + + return { + getTooltipText, + } +} diff --git a/app/composables/useCommandPalettePackageCommands.ts b/app/composables/useCommandPalettePackageCommands.ts index 5533acd5c4..1cc5d3f749 100644 --- a/app/composables/useCommandPalettePackageCommands.ts +++ b/app/composables/useCommandPalettePackageCommands.ts @@ -106,6 +106,23 @@ export function useCommandPalettePackageCommands( }, }, }, + { + id: 'package-sizes', + group: 'package', + label: t('command_palette.package.sizes'), + keywords: [resolvedContext.packageName, t('shortcuts.open_sizes')], + iconClass: 'i-lucide:list-tree', + active: route.name === 'package-sizes', + activeLabel: activeLabel(route.name === 'package-sizes', t('command_palette.here')), + to: { + name: 'package-sizes', + params: { + org: org || undefined, + packageName: name, + version: resolvedContext.resolvedVersion, + }, + }, + }, ] if (resolvedContext.tarballUrl) { diff --git a/app/pages/package-sizes/[[org]]/[packageName].vue b/app/pages/package-sizes/[[org]]/[packageName].vue new file mode 100644 index 0000000000..dfc1957bfa --- /dev/null +++ b/app/pages/package-sizes/[[org]]/[packageName].vue @@ -0,0 +1,280 @@ + + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 934d81e471..35ca3d67ae 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -1,6 +1,7 @@